Web Security for Frontend Devs · Part 4 — CSRF & SameSite Cookies
CSRF abuses the gap SOP leaves open: the browser sends cookies cross-origin but blocks reading the reply. SameSite cookies, anti-CSRF tokens, Origin checks, and layered defenses — with exercises.
Phần 4/10 trong series Web Security for Frontend Devs. Trước: Tiếp:
Đây là Phần 4 của series 10 bài về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh. Bạn đã biết từ Phần 1 rằng SOP chặn đọc cross-origin, không chặn gửi. Cross-Site Request Forgery (CSRF) xảy ra khi server coi “ai gửi cookie này” là “ai có ý định thực hiện hành động” — dù trang độc hại mới là thứ kích hoạt request.
Ý cốt lõi
Khi trình duyệt nói chuyện với bank.com, nó tự đính kèm cookie phiên bank.com — không cần JavaScript trên bank.com. Đó là hành vi credential môi trường từ Phần 1.
SOP nghĩa là evil.com không đọc được phản hồi từ bank.com. Nó không có nghĩa evil.com không thể khiến trình duyệt gửi request đổi trạng thái kèm cookie đó. Server thấy cookie phiên hợp lệ và thường kết luận “user đã xác thực đã làm X”. CSRF là lỗi tin cookie, không phải lỗi rò đọc.
Mô hình tư duy: SOP bảo vệ bí mật phản hồi; CSRF lợi dụng toàn vẹn trạng thái phía server khi bằng chứng duy nhất về ý định chỉ là cookie trình duyệt tự gửi.
Diễn biến tấn công cụ thể
- Nạn nhân đăng nhập
https://bank.com. - Nạn nhân mở
https://evil.comở tab khác (hoặc bấm link). evil.comkích hoạt request tớibank.com— ví dụ form ẩn tự submit hoặc<img>trỏ URL đổi trạng thái.- Trình duyệt gửi request kèm cookie
bank.com.evil.comkhông thấy body phản hồi (SOP), nhưng giao dịch có thể đã xong.
Form ẩn (CSRF POST kinh điển):
<!-- hosted on https://evil.com -->
<form id="csrf" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.getElementById('csrf')?.submit();
</script>
Ảnh beacon (GET CSRF — chỉ thành công nếu app sai lầm đổi trạng thái bằng GET):
<img src="https://bank.com/transfer?to=attacker&amount=10000" width="0" height="0" alt="" />
Framework hiện đại thường từ chối POST cross-site không có gesture, nhưng đừng chỉ trông chờ điều đó — giả định kẻ tấn công vẫn tìm được endpoint còn nhận cookie.
Phương thức an toàn và mục tiêu thật của CSRF
HTTP định nghĩa phương thức an toàn — GET, HEAD, OPTIONS — theo quy ước chỉ đọc: không đổi trạng thái server. Không bao giờ dùng GET cho chuyển tiền, xóa, đổi mật khẩu, hay cập nhật cài đặt. Nếu làm vậy, <img src>, prefetch, hoặc điều hướng top-level có thể thành công cụ CSRF.
Phòng CSRF quan trọng nhất với request đổi trạng thái: POST, PUT, PATCH, DELETE. Sửa GET không an toàn là vệ sinh bắt buộc; không thay thế token CSRF hay SameSite cho phiên dựa cookie.
Phòng thủ nhiều lớp
Không header hay thuộc tính cookie đơn lẻ nào chặn hết CSRF mọi nơi. App production xếp các lớp độc lập để một lớp hỏng không dẫn tới chiếm tài khoản:
- Cookie SameSite — giảm lần cookie phiên đi kèm request cross-site.
- Token chống CSRF — chứng minh request đến từ UI của bạn, không phải form giả trên site khác.
- Kiểm tra Origin / Referer phía server cho thao tác nhạy cảm.
- Header tùy chỉnh + CORS — lớp hỗ trợ cho app kiểu API.
Cookie SameSite
Thuộc tính SameSite bảo trình duyệt khi nào gửi kèm cookie trên request cross-site. Trình duyệt hiện đại mặc định cookie mới là SameSite=Lax nếu bạn không khai báo.
| Value | Hành vi cross-site (tóm tắt) |
|---|---|
Strict | Cookie không bao giờ gửi trên điều hướng/subresource cross-site. Mạnh nhất; có thể gãy luồng hợp lệ (OAuth, link email). |
Lax (default) | Gửi trên điều hướng GET top-level (vd link email → site bạn). Không gửi trên POST cross-site, fetch có credentials, hay subresource như <img> / <iframe>. |
None | Cookie gửi trên request cross-site — bắt buộc thêm Secure (chỉ HTTPS). Cần cho widget nhúng, SSO, API cross-site — và mở lại bề mặt CSRF trừ khi bạn thêm token. |
Ví dụ Set-Cookie cho phiên:
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
Lax chặn gì trong thực tế: form POST ẩn từ evil.com tới bank.com — trình duyệt không gửi cookie phiên, server nên từ chối. Lax vẫn cho phép: nạn nhân bấm link và vào bank.com/transfer?... bằng GET — nếu app đổi trạng thái bằng GET, bạn vẫn lỗ hổng.
SameSite là vệ sinh nền tuyệt vời — nhưng không thay hoàn toàn token chống CSRF trong mọi triển khai: domain đăng ký chung, client cũ, nhúng SameSite=None cố ý, và GET top-level Lax vẫn để lại khe hở.
Token chống CSRF
Server phát hành bí mật không đoán được gắn phiên. UI hợp lệ phải gửi lại trên mỗi request đổi trạng thái; server từ chối nếu không khớp. Vì SOP, evil.com không đọc được token trong trang bạn hay cookie SameSite — nên không giả được giá trị đúng.
Mẫu synchronizer token
Server lưu csrfToken phía server (session/cache). HTML hoặc JSON bootstrap đưa cho JS; client gửi trong header hoặc field form.
// client — same-origin fetch only; token from meta tag or bootstrap
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (!token) throw new Error('Missing CSRF token');
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
},
body: JSON.stringify({ to: 'savings', amount: 100 }),
credentials: 'same-origin',
});
// server — compare constant-time; reject if missing or wrong
import { timingSafeEqual } from 'node:crypto';
function assertCsrf(req: Request, sessionToken: string): void {
const header = req.headers.get('X-CSRF-Token');
if (!header || header.length !== sessionToken.length) {
throw new Error('CSRF validation failed');
}
const a = Buffer.from(header);
const b = Buffer.from(sessionToken);
if (!timingSafeEqual(a, b)) throw new Error('CSRF validation failed');
}
Mẫu double-submit cookie
Server set cookie không httpOnly csrf=random và yêu cầu cùng giá trị trong X-CSRF-Token (hoặc field form). Site tấn công không đọc cookie của bạn (SOP), nên không set header khớp. Đổi lại: XSS trên origin bạn đọc được cookie và phá mẫu này — nên XSS (Phần 2) và CSP (Phần 3) vẫn quan trọng.
Dùng token ngẫu nhiên mật mã (vd 32+ byte từ crypto.getRandomValues), đổi khi đăng nhập, validate trên mọi route đổi trạng thái.
Kiểm tra Origin và Referer
Trình duyệt gửi Origin trên POST/PUT/DELETE cross-origin và request CORS; Referer thường có trên điều hướng và form post. Với thao tác nhạy cảm, server chỉ nên cho phép origin đã biết:
const ALLOWED = new Set(['https://app.example.com']);
function assertSameOrigin(req: Request): void {
const origin = req.headers.get('Origin');
if (origin && ALLOWED.has(origin)) return;
const referer = req.headers.get('Referer');
if (referer?.startsWith('https://app.example.com/')) return;
throw new Error('Forbidden');
}
Referer có thể bị cắt bởi công cụ riêng tư hoặc Referrer-Policy — coi Origin là chính khi có, và không chỉ dựa vào kiểm tra này trên API nhận cookie mà không có token.
Header tùy chỉnh và CORS (lớp hỗ trợ)
Form cross-site hoặc <img> chỉ kích hoạt request đơn giản — không set header tùy ý như X-CSRF-Token hay X-Requested-With. JS của bạn trên app.example.com có thể set header đó trên fetch same-origin.
Bắt buộc header tùy chỉnh trên mutation là lớp an toàn kép hữu ích cho API JSON — kèm CORS đúng (Phần 6) để chỉ origin bạn đọc được phản hồi cross-origin. Yếu hơn synchronizer token cho form HTML phiên cookie cổ điển trừ khi mọi mutation đi qua fetch có header đó.
await fetch('/api/settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': token,
},
body: JSON.stringify({ theme: 'dark' }),
credentials: 'include',
});
CSRF vs cách lưu auth (xem trước Phần 5)
| Storage | CSRF risk | Rủi ro CSRF / Mối lo chính nếu lộ |
|---|---|---|
| httpOnly session cookie | Cao — trình duyệt tự gửi trên request cross-site trừ khi SameSite + token chặn | Cần phòng CSRF (phần này) |
Authorization: Bearer in JS | Thấp hơn — trang tấn công không set header đó trên công cụ cross-site đơn giản như cookie âm thầm đi kèm | XSS đánh cắp token từ memory/storage (Phần 5) |
Hầu hết SPA production với cookie httpOnly dùng SameSite=Lax (hoặc Strict) + synchronizer token + kiểm tra Origin. App hybrid gọi API cross-origin bằng cookie phải khớp SameSite=None; Secure, CORS Access-Control-Allow-Credentials, và token — xem Phần 6 — CORS Explained.
Checklist frontend
Trước khi ship auth dựa cookie:
- Cookie phiên:
HttpOnly,Secure,SameSite=Lax(hoặcStrictnếu luồng cho phép). - Mọi
POST/PUT/PATCH/DELETEvalidate phía server bằng token CSRF hoặc tương đương. - Không đổi trạng thái trên
GET. - Mutation từ
fetchgửi header token; ghi rõ hợp đồng cho team BFF/API. - Đừng giả định “API chỉ JSON” là không CSRF — trình duyệt vẫn POST body form kèm cookie trừ khi SameSite chặn.
Bài tập / Exercises
1. Mỗi câu một câu: vì sao SOP không chặn CSRF, và vì sao SameSite=Lax chặn form POST ẩn trong câu chuyện biên “gửi” của Phần 1.
Lời giải
SOP chỉ ngăn evil.com đọc phản hồi bank.com; trình duyệt vẫn gửi POST và cookie. SameSite=Lax không gửi cookie phiên trên POST cross-site, nên bank.com thấy request chưa đăng nhập và từ chối chuyển tiền.
2. PM muốn GET /api/delete-account?id=123 vì “đơn giản.” Nêu hai lý do liên quan CSRF để từ chối.
Lời giải
GET không được đổi trạng thái — trang độc có thể kích hoạt bằng <img src>, link, prefetch không cần body POST. SameSite=Lax vẫn gửi cookie trên điều hướng GET top-level — link trong email/chat có thể xóa tài khoản khi nạn nhân bấm.
3. Phác synchronizer: token tạo ở đâu, SPA đọc thế nào, header nào gửi, server so sánh gì.
Lời giải
Server sinh token ngẫu nhiên lúc đăng nhập, lưu session. Trang có <meta name="csrf-token" content="…"> hoặc JSON /api/bootstrap. SPA đọc token, gửi X-CSRF-Token trên POST/PATCH/DELETE. Server so sánh header với giá trị session bằng so sánh constant-time; 403 nếu lệch.
Nâng cao:So sánh double-submit cookie vs synchronizer token cho app có XSS đã biết ở comment. Mẫu nào gãy trước, vì sao?
Lời giải
Double-submit gãy trước: cookie CSRF đọc được bằng JS (httpOnly=false), script inject đọc và gửi cả cookie và header khớp từ trình duyệt nạn nhân. Synchronizer cũng gãy nếu token lộ trong DOM/meta — XSS vẫn đánh cắp. Sửa XSS (Phần 2); đừng coi token CSRF thay cho phòng XSS.
Điểm chính
- CSRF lợi dụng khe gửi từ Phần 1: cookie vẫn đi kèm dù
evil.comkhông đọc được phản hồi. - Không đổi trạng thái bằng GET; phòng CSRF nhắm phương thức đổi trạng thái.
SameSite=Lax(mặc định) chặn hầu hết CSRF POST cross-site; biết những gì nó vẫn cho phép.- Token chống CSRF + kiểm tra Origin + header tùy chỉnh tùy chọn = phòng thủ nhiều lớp.
- Cookie httpOnly ⇒ cần phòng CSRF; bearer token trong JS dồn rủi ro sang XSS (Phần 5).
Tiếp theo
Phần 5 — Auth Tokens & Secure Storage: đặt session JWT ở đâu, vì sao localStorage thua cookie httpOnly, và lựa chọn lưu trữ đổi ưu tiên CSRF hay XSS.