jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

Part 4 of 10 in the Web Security for Frontend Devs series {Phần 4/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 3 — Content Security Policy (CSP) · Next {Tiếp}: Part 5 — Auth Tokens & Secure Storage.

This is Part 4 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đâ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}. You already know from Part 1 that the Same-Origin Policy blocks cross-origin reads, not sends {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) is what happens when a server treats “whoever sent this cookie” as “whoever intended the action” — even though a malicious page triggered the request {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}.


The core insight {Ý cốt lõi}

When your browser talks to bank.com, it automatically attaches bank.com session cookies — no JavaScript on bank.com required {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}. That is ambient credential behavior from Part 1 {Đó là hành vi credential môi trường từ Phần 1}.

SOP means evil.com cannot read the response from bank.com {SOP nghĩa là evil.com không đọc được phản hồi từ bank.com}. It does not mean evil.com cannot cause the browser to send a state-changing request with those cookies attached {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 đó}. The server sees a valid session cookie and often says “authenticated user did X” {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 is a trust-on-cookie bug, not a read-leak bug {CSRF là lỗi tin cookie, không phải lỗi rò đọc}.

Mental model {Mô hình tư duy}: SOP protects confidentiality of the response; CSRF abuses integrity of server-side state when the only proof of intent is a cookie the browser ships automatically {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}.


A concrete attack walkthrough {Diễn biến tấn công cụ thể}

  1. The victim logs into https://bank.com {Nạn nhân đăng nhập https://bank.com}. The server sets a session cookie.
  2. The victim visits https://evil.com in another tab (or clicks a link) {Nạn nhân mở https://evil.com ở tab khác (hoặc bấm link)}.
  3. evil.com triggers a request to bank.com — for example a hidden auto-submitting form or an <img> that hits a state-changing URL {evil.com kích hoạt request tới bank.com — ví dụ form ẩn tự submit hoặc <img> trỏ URL đổi trạng thái}.
  4. The browser sends the request with bank.com cookies {Trình duyệt gửi request kèm cookie bank.com}. evil.com never sees the response body (SOP), but the transfer may already have happened {evil.com không thấy body phản hồi (SOP), nhưng giao dịch có thể đã xong}.
Victim browser logged in @ bank evil.com hidden form / img POST /transfer + cookie sent automatically bank.com trusts the cookie fix: SameSite cookies + anti-CSRF token server checks token that evil.com cannot read
evil.com triggers a request; the browser attaches bank.com cookies; the server trusts the session

Hidden form (classic POST CSRF) {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>

Image beacon (GET CSRF — only works if the app wrongly mutates on GET) {Ả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="" />

Modern frameworks often reject non-simple cross-site POSTs without user gesture, but never rely on that alone — assume attackers will find endpoints that still accept cookies {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}.


Safe methods and what CSRF actually targets {Phương thức an toàn và mục tiêu thật của CSRF}

HTTP defines safe methodsGET, HEAD, OPTIONS — as read-only by convention: they should not change server state {HTTP định nghĩa phương thức an toànGET, HEAD, OPTIONS — theo quy ước chỉ đọc: không đổi trạng thái server}. Never use GET for transfers, deletes, password changes, or settings updates {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}. If you do, <img src>, <link prefetch>, or a top-level navigation can become a CSRF gadget {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}.

CSRF defenses matter most on state-changing requests: POST, PUT, PATCH, DELETE {Phòng CSRF quan trọng nhất với request đổi trạng thái: POST, PUT, PATCH, DELETE}. Fixing unsafe GET endpoints is necessary hygiene; it is not a substitute for CSRF tokens or SameSite on cookie-based sessions {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}.


Defense in depth {Phòng thủ nhiều lớp}

No single header or cookie attribute ends CSRF everywhere {Không header hay thuộc tính cookie đơn lẻ nào chặn hết CSRF mọi nơi}. Production apps stack independent layers so one miss does not mean account takeover {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}:

  1. SameSite cookies — reduce when session cookies ride on cross-site requests {Cookie SameSite — giảm lần cookie phiên đi kèm request cross-site}.
  2. Anti-CSRF tokens — prove the request came from your origin’s UI, not a forged form on another 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}.
  3. Origin / Referer validation on the server for sensitive mutations {Kiểm tra Origin / Referer phía server cho thao tác nhạy cảm}.
  4. Custom headers + CORS — supporting layer for API-style apps {Header tùy chỉnh + CORS — lớp hỗ trợ cho app kiểu API}.

The SameSite attribute tells the browser when to include a cookie on cross-site requests {Thuộc tính SameSite bảo trình duyệt khi nào gửi kèm cookie trên request cross-site}. All modern browsers default new cookies to SameSite=Lax if you omit the attribute {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}.

ValueCross-site behavior (summary) {Hành vi cross-site (tóm tắt)}
StrictCookie never sent on cross-site navigations or subresources {Cookie không bao giờ gửi trên điều hướng/subresource cross-site}. Strongest; can break legitimate flows (OAuth return, email deep links) {Mạnh nhất; có thể gãy luồng hợp lệ (OAuth, link email)}.
Lax (default)Sent on top-level GET navigations (e.g. link from email → your site) {Gửi trên điều hướng GET top-level (vd link email → site bạn)}. Not sent on cross-site POST, fetch with credentials, or subresources like <img> / <iframe> {Không gửi trên POST cross-site, fetch có credentials, hay subresource như <img> / <iframe>}.
NoneCookie sent on cross-site requests — must also set Secure (HTTPS only) {Cookie gửi trên request cross-site — bắt buộc thêm Secure (chỉ HTTPS)}. Needed for embedded widgets, some SSO, cross-site APIs — and re-opens CSRF surface unless you add tokens {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}.

Example Set-Cookie for a session {Ví dụ Set-Cookie cho phiên}:

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

What Lax blocks in practice {Lax chặn gì trong thực tế}: the hidden POST form from evil.com to bank.com — the browser withholds the session cookie, so the server should reject the action {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}. What Lax still allows {Lax vẫn cho phép}: victim clicks a link and lands on bank.com/transfer?... via GET — if your app mutates on GET, you are still vulnerable {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 is excellent baseline hygiene — but not a complete substitute for anti-CSRF tokens in every deployment {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}: shared registrable domains (app.example.comapi.example.com), legacy clients, intentional SameSite=None embeds, and Lax top-level GET navigations all leave gaps {domain đăng ký chung, client cũ, nhúng SameSite=None cố ý, và GET top-level Lax vẫn để lại khe hở}.


Anti-CSRF tokens {Token chống CSRF}

The server issues an unpredictable secret tied to the session. The legitimate UI must echo it on each state-changing request; the server rejects mismatches {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}. Because of SOP, evil.com cannot read a token stored in your page or in a SameSite cookie — so it cannot forge a correct value {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}.

Synchronizer token pattern {Mẫu synchronizer token}

Server stores csrfToken server-side (session or cache). HTML or bootstrap JSON exposes it to JS; the client sends it in a header or form field {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');
}

Server sets a non-httpOnly cookie csrf=random and requires the same value in X-CSRF-Token (or a form field). Attacker sites cannot read your cookies (SOP), so they cannot set the matching header {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}. Trade-off: any XSS on your origin can read the cookie and defeat this pattern — which is why XSS (Part 2) and CSP (Part 3) still matter {Đổ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}.

Use cryptographically random tokens (e.g. 32+ bytes from crypto.getRandomValues), rotate on login, and validate on every state-changing route {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}.


Origin and Referer checks {Kiểm tra Origin và Referer}

Browsers send Origin on cross-origin POST/PUT/DELETE and on CORS requests; Referer is often present on navigations and form posts {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}. For sensitive mutations, the server should allow only known origins {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 can be stripped by privacy tools or Referrer-Policy — treat Origin as primary when present, and never use these checks alone on APIs that accept cookie auth without tokens {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}.


Custom headers and CORS (supporting layer) {Header tùy chỉnh và CORS (lớp hỗ trợ)}

A cross-site form or <img> can only trigger simple requests — it cannot set arbitrary headers like X-CSRF-Token or X-Requested-With {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}. Your own JavaScript on app.example.com can set those headers on same-origin fetch {JS của bạn trên app.example.com có thể set header đó trên fetch same-origin}.

Requiring a custom header on mutations is a useful belt-and-suspenders check for JSON APIs — paired with proper CORS (Part 6) so only your origins get readable cross-origin responses {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}. It is weaker than synchronizer tokens for classic cookie-session HTML forms unless every mutation goes through fetch with that header {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 how you store auth (preview of Part 5) {CSRF vs cách lưu auth (xem trước Phần 5)}

StorageCSRF riskPrimary concern if compromised {Rủi ro CSRF / Mối lo chính nếu lộ}
httpOnly session cookieHigh — browser sends it automatically on cross-site requests unless SameSite + tokens stop it {Cao — trình duyệt tự gửi trên request cross-site trừ khi SameSite + token chặn}You need CSRF defense (this part) {Cần phòng CSRF (phần này)}
Authorization: Bearer in JSLower — attacker pages cannot set that header on simple cross-site gadgets the way cookies attach silently {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 stealing the token from memory/storage (Part 5) {XSS đánh cắp token từ memory/storage (Phần 5)}

Most production SPAs with httpOnly cookies use SameSite=Lax (or Strict) + synchronizer token + Origin check {Hầu hết SPA production với cookie httpOnly dùng SameSite=Lax (hoặc Strict) + synchronizer token + kiểm tra Origin}. Hybrid apps calling cross-origin APIs with cookies must align SameSite=None; Secure, CORS Access-Control-Allow-Credentials, and tokens — see Part 6 — CORS Explained {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}.


Frontend checklist {Checklist frontend}

Before you ship cookie-based auth {Trước khi ship auth dựa cookie}:

  • Session cookie: HttpOnly, Secure, SameSite=Lax (or Strict if flows allow) {Cookie phiên: HttpOnly, Secure, SameSite=Lax (hoặc Strict nếu luồng cho phép)}.
  • Every POST/PUT/PATCH/DELETE validated server-side with a CSRF token or equivalent {Mọi POST/PUT/PATCH/DELETE validate phía server bằng token CSRF hoặc tương đương}.
  • No state change on GET {Không đổi trạng thái trên GET}.
  • Mutations from fetch send the token header; document the contract for your BFF/API team {Mutation từ fetch gửi header token; ghi rõ hợp đồng cho team BFF/API}.
  • Do not assume “JSON-only API” means no CSRF — browsers can still POST form bodies with cookies unless SameSite blocks them {Đừ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. In one sentence each, explain why SOP does not stop CSRF, and why SameSite=Lax does stop the hidden POST form from Part 1’s “send boundary” story {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}.

Solution {Lời giải}

SOP only prevents evil.com from reading bank.com’s response; the browser still sends the POST and cookies {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 withholds the session cookie on cross-site POST, so bank.com should see an unauthenticated request and reject the transfer {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. Your PM wants GET /api/delete-account?id=123 for “simplicity.” List two CSRF-related reasons to refuse {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}.

Solution {Lời giải}

(1) GET must not mutate — a malicious page can trigger it via <img src>, link, or prefetch without a POST body {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}. (2) SameSite=Lax still sends cookies on top-level GET navigations — a crafted link in email or chat can delete the account when the victim clicks {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. Sketch the synchronizer flow: where the token is created, how the SPA reads it, which header it sends, and what the server compares {Phác synchronizer: token tạo ở đâu, SPA đọc thế nào, header nào gửi, server so sánh gì}.

Solution {Lời giải}

Server generates random token at login, stores in session {Server sinh token ngẫu nhiên lúc đăng nhập, lưu session}. Page includes <meta name="csrf-token" content="…"> or /api/bootstrap JSON {Trang có <meta name="csrf-token" content="…"> hoặc JSON /api/bootstrap}. SPA reads token, sends X-CSRF-Token on POST/PATCH/DELETE {SPA đọc token, gửi X-CSRF-Token trên POST/PATCH/DELETE}. Server compares header to session value with constant-time equality; 403 if mismatch {Server so sánh header với giá trị session bằng so sánh constant-time; 403 nếu lệch}.

Stretch {Nâng cao}: Compare double-submit cookie vs synchronizer token for an app with a known XSS in a comment field. Which pattern fails first, and why? {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?}

Solution {Lời giải}

Double-submit fails first {Double-submit gãy trước}: the CSRF cookie is readable by JS (httpOnly=false), so injected script reads it and sends both cookie and matching header from the victim’s browser {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 also falls if the token is exposed in the DOM/meta — XSS can exfiltrate it too {Synchronizer cũng gãy nếu token lộ trong DOM/meta — XSS vẫn đánh cắp}. Fix the XSS (Part 2); do not treat CSRF tokens as a substitute for XSS prevention {Sửa XSS (Phần 2); đừng coi token CSRF thay cho phòng XSS}.


Key takeaways {Điểm chính}

  • CSRF exploits the send gap from Part 1: cookies ride along even when evil.com cannot read the response {CSRF lợi dụng khe gửi từ Phần 1: cookie vẫn đi kèm dù evil.com không đọc được phản hồi}.
  • Never mutate on GET; CSRF defenses target state-changing methods {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 (default) blocks most cross-site POST CSRF; know what it still allows {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}.
  • Anti-CSRF tokens + Origin checks + optional custom headers = defense in depth {Token chống CSRF + kiểm tra Origin + header tùy chỉnh tùy chọn = phòng thủ nhiều lớp}.
  • httpOnly cookies ⇒ you need CSRF protection; bearer tokens in JS shift risk toward XSS (Part 5) {Cookie httpOnly ⇒ cần phòng CSRF; bearer token trong JS dồn rủi ro sang XSS (Phần 5)}.

Next up {Tiếp theo}

Part 5 — Auth Tokens & Secure Storage: where to put session JWTs, why localStorage loses to httpOnly cookies, and how your storage choice changes whether CSRF or XSS is the bigger fire {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}. Continue to Part 5 — Auth Tokens & Secure Storage.