jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Security for Frontend Devs · Part 6 — CORS Explained

CORS is not a firewall for your API — it relaxes SOP so browsers can read cross-origin responses when the server opts in. Preflight, credentials, misconfigurations, and what CORS cannot fix — with exercises.

Part 6 of 10 in the Web Security for Frontend Devs series {Phần 6/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 5 — Auth Tokens & Secure Storage · Next {Tiếp}: Part 7 — Clickjacking & Framing.

This is Part 6 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đây là Phần 6 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}. If you have heard “enable CORS to secure the API,” you have the model backwards {Nếu bạn từng nghe “bật CORS để bảo vệ API,” bạn đang hiểu ngược mô hình}. Cross-Origin Resource Sharing (CORS) is a browser mechanism that relaxes the Same-Origin Policy from Part 1 so JavaScript on one origin can read a cross-origin response — but only when the responding server explicitly allows it {Cross-Origin Resource Sharing (CORS) là cơ chế trình duyệt nới lỏng Same-Origin Policy ở Phần 1 để JavaScript trên một origin đọc phản hồi cross-origin — nhưng chỉ khi server phản hồi cho phép rõ ràng}. Your API still needs its own authentication and authorization; CORS is not that layer {API của bạn vẫn cần xác thực và phân quyền riêng; CORS không phải lớp đó}.


The #1 misconception {Hiểu lầm số 1}

CORS does not protect your server from attackers. {CORS không bảo vệ server khỏi kẻ tấn công.}

Attackers are not limited to browser fetch from a victim tab {Kẻ tấn công không bị giới hạn bởi fetch trình duyệt từ tab nạn nhân}. They use curl, server-side scripts, compromised backends, and mobile apps — none of which enforce CORS {Họ dùng curl, script phía server, backend bị xâm nhập, app mobile — không cái nào áp CORS}. CORS only constrains honest web pages running in a victim’s browser when they try to read a response with JavaScript {CORS chỉ ràng buộc trang web hợp lệ chạy trong trình duyệt nạn nhân khi chúng cố đọc phản hồi bằng JavaScript}.

Think of it this way {Hãy nghĩ như sau}:

LayerWho enforces itWhat it does
SOP (default)BrowserBlocks JS from reading cross-origin responses
CORSBrowser + server’s Access-Control-* headersOpt-in to allow specific origins to read
Authn / authzYour serverDecides whether the caller may perform the action

Mental model {Mô hình tư duy}: CORS is a read permission for browser JS, not an API firewall {CORS là quyền đọc cho JS trình duyệt, không phải tường lửa API}. The server that owns the data publishes who may see responses in a browser context {Server sở hữu dữ liệu công bố ai được xem phản hồi trong ngữ cảnh trình duyệt}.


SOP recap: send vs read {Ôn SOP: gửi vs đọc}

From Part 1: the browser will send many cross-origin requests (including with cookies), but blocks your page’s JavaScript from reading the body unless CORS allows it {Từ Phần 1: trình duyệt vẫn gửi nhiều request cross-origin (kể cả kèm cookie), nhưng chặn JS trên trang bạn đọc body trừ khi CORS cho phép}.

https://app.example.com  (your SPA)

        │  fetch('https://api.example.com/me', { credentials: 'include' })

https://api.example.com  (API)

        ├─ Request arrives (cookies may attach)     ← SOP does NOT stop this
        └─ Response body hidden from JS unless     ← CORS decides read access
           Access-Control-Allow-Origin allows app.example.com

That send/read split is why CSRF (Part 4) and CORS solve different problems {Khe tách gửi/đọc đó là lý do CSRF (Phần 4) và CORS giải hai vấn đề khác nhau}.

app.com (JS) fetch api.other.com api.other.com sets ACA-* headers 1 · OPTIONS preflight (Origin, method, headers) 2 · Access-Control-Allow-Origin: app.com 3 · real request (only if allowed) CORS relaxes SOP — the SERVER decides who may read the response
Preflight asks permission; the server opts in with Access-Control-Allow-* — only then may JS read the response

Simple requests vs preflighted requests {Request đơn giản vs cần preflight}

The browser classifies some cross-origin fetch/XHR as simple (no preflight) and everything else triggers an OPTIONS preflight {Trình duyệt phân loại một số fetch/XHR cross-origin là đơn giản (không preflight), phần còn lại kích hoạt OPTIONS preflight}.

A cross-origin request is simple only when all of these hold {Request cross-origin đơn giản chỉ khi tất cả điều kiện sau đúng}:

  • Method is GET, HEAD, or POST {Phương thức là GET, HEAD, hoặc POST}.
  • Only CORS-safelisted request headers (e.g. Accept, Content-Type with allowed values, Content-Language, …) {Chỉ header request trong safelist CORS (vd Accept, Content-Type với giá trị cho phép, …)}.
  • If Content-Type is set on POST, it must be application/x-www-form-urlencoded, multipart/form-data, or text/plain {Nếu POSTContent-Type, phải là application/x-www-form-urlencoded, multipart/form-data, hoặc text/plain}.

Preflight is required when you cross any of those lines — typical triggers {Cần preflight khi vượt bất kỳ ranh giới nào — kích hoạt thường gặp}:

  • Methods like PUT, PATCH, DELETE {Phương thức PUT, PATCH, DELETE}.
  • Headers like Authorization, X-CSRF-Token, or any custom header {Header như Authorization, X-CSRF-Token, hay header tùy chỉnh}.
  • Content-Type: application/json on POST {Content-Type: application/json trên POST}.

Example preflight — browser asks before the real PATCH {Ví dụ preflight — trình duyệt hỏi trước PATCH thật}:

OPTIONS /api/profile HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization

Server must answer with explicit allowance (status 204 or 200 is common) {Server phải trả lời cho phép rõ ràng (status 204 hoặc 200 thường gặp)}:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Only if preflight succeeds does the browser send the real PATCH and expose the response body to JS {Chỉ khi preflight thành công trình duyệt mới gửi PATCH thật và cho JS đọc body phản hồi}.


Key response headers {Header phản hồi quan trọng}

HeaderRole
Access-Control-Allow-OriginWhich origins may read the response in JS (echo a specific origin, not * when using credentials)
Access-Control-Allow-MethodsMethods allowed after preflight
Access-Control-Allow-HeadersRequest headers allowed after preflight
Access-Control-Allow-Credentials: trueAllows cookies / HTTP auth / client certs with credentials: 'include'
Access-Control-Max-AgeHow long the browser may cache preflight result (seconds)
Access-Control-Expose-HeadersWhich response headers beyond the safelist JS may read (e.g. X-Request-Id)

Access-Control-Allow-Origin must echo one concrete origin (or a dynamic allowlist you validate server-side) — not a blind mirror of untrusted input {Access-Control-Allow-Origin phải echo một origin cụ thể (hoặc allowlist bạn validate phía server) — không phản chiếu mù input không tin cậy}.

# Good — explicit partner SPA
Access-Control-Allow-Origin: https://app.example.com

# Good for public read-only JSON (no cookies / no credentials)
Access-Control-Allow-Origin: *

# Bad with credentials (browser will reject)
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Credentials and CORS {Credentials và CORS}

When the client sends cookies or HTTP auth, three rules must align {Khi client gửi cookie hoặc HTTP auth, ba quy tắc phải khớp}:

  1. Client: credentials: 'include' (or withCredentials: true on XHR) {Client: credentials: 'include' (hoặc withCredentials: true trên XHR)}.
  2. Server: Access-Control-Allow-Credentials: true {Server: Access-Control-Allow-Credentials: true}.
  3. Server: Access-Control-Allow-Origin is the exact origin string — never * {Server: Access-Control-Allow-Origin là chuỗi origin chính xáckhông *}.
const res = await fetch('https://api.example.com/me', {
  method: 'GET',
  credentials: 'include',
  headers: {
    Accept: 'application/json',
  },
});

If any leg is wrong, DevTools shows a CORS error even when the server returned 200 and Set-Cookie — because the browser withholds the body from JavaScript {Nếu một chân sai, DevTools báo lỗi CORS dù server trả 200 và Set-Cookie — vì trình duyệt không giao body cho JavaScript}. The session may still have changed server-side; your SPA simply cannot read the proof {Phiên vẫn có thể đổi phía server; SPA chỉ không đọc được bằng chứng}.


Dangerous misconfigurations {Cấu hình nguy hiểm}

These are real vulnerabilities, not stylistic nitpicks {Đây là lỗ hổng thật, không phải góp ý phong cách}.

Reflecting Origin blindly with credentials {Phản chiếu Origin mù kèm credentials}

Vulnerable pattern {Mẫu dễ tổn thương}:

// NEVER: trusts any Origin when credentials are enabled
function setCors(res: { setHeader: (k: string, v: string) => void }, req: { headers: { origin?: string } }) {
  const origin = req.headers.origin ?? '';
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

Any site the victim visits can set Origin: https://evil.com, get Access-Control-Allow-Origin: https://evil.com, and — with credentials: 'include'read authenticated JSON from your API in the victim’s browser {Bất kỳ trang nạn nhân ghé đều gửi Origin: https://evil.com, nhận Access-Control-Allow-Origin: https://evil.com, và với credentials: 'include'đọc JSON đã xác thực từ API trong trình duyệt nạn nhân}.

Fix — constant-time check against a fixed allowlist, echo only on match {Sửa — kiểm tra allowlist cố định, chỉ echo khi khớp}:

const ALLOWED = new Set(['https://app.example.com', 'https://staging.example.com']);

function setCors(
  res: { setHeader: (k: string, v: string) => void },
  req: { headers: { origin?: string } },
) {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
}

Access-Control-Allow-Origin: * on internal APIs {* trên API nội bộ}

Public read-only endpoints sometimes use * without credentials {Endpoint công khai chỉ đọc đôi khi dùng * không kèm credentials}. Putting * on an internal admin or employee API exposes readable responses to any origin’s JavaScript if someone can trick a logged-in browser into calling it {Đặt * trên API nội bộ admin/nhân viên cho phép JS từ mọi origin đọc phản hồi nếu kẻ xấu lừa trình duyệt đã đăng nhập gọi API}. Lock internal APIs to VPN / mTLS / network policy and tight CORS {Khóa API nội bộ bằng VPN / mTLS / chính sách mạng CORS chặt}.

Trusting the null origin {Tin origin null}

Sandboxed iframes, file://, and some redirects send Origin: null {iframe sandbox, file://, một số redirect gửi Origin: null}. Allowlisting null is almost always wrong for credentialed APIs {Cho phép null hầu như luôn sai với API có credentials}.

Overly broad allowlists {Allowlist quá rộng}

https://*.example.com via regex mistakes, stale staging domains, or “allow all subdomains” can include attacker-controlled hosts if DNS or tenant signup is loose {https://*.example.com do regex sai, domain staging cũ, hay “cho mọi subdomain” có thể gồm host kẻ tấn công kiểm soát nếu DNS hoặc đăng ký tenant lỏng}. Prefer an explicit set {Ưu tiên tập hợp rõ ràng}.


What CORS does NOT do {CORS KHÔNG làm gì}

Be explicit so you do not ship the wrong defense {Nói rõ để không triển khai nhầm lớp phòng thủ}:

MythReality
”CORS blocks evil sites from calling my API”The request still hits your server; only JS read is gated
”No CORS header = secure API”Non-browser clients ignore CORS entirely
”CORS prevents CSRF”Part 4 — CSRF is about cookies on send; CORS is about read after send
”CORS authenticates users”Only your session/JWT/API-key logic does; CORS never validates passwords

Custom headers can reduce simple CSRF in API-only setups (attackers cannot set Authorization from a form), but that is a side effect — not the purpose of CORS {Header tùy chỉnh có thể giảm CSRF đơn giản trong setup chỉ API (form không set Authorization được), nhưng đó là hiệu ứng phụ — không phải mục đích CORS}. Cookie-based apps still need SameSite + anti-CSRF tokens {App dựa cookie vẫn cần SameSite + token chống CSRF}.


Debugging CORS in DevTools {Debug CORS trong DevTools}

When fetch fails with a message like “blocked by CORS policy” {Khi fetch fail với “blocked by CORS policy”}:

  1. Open Network → select the failed request (often the OPTIONS preflight) {Mở Network → chọn request lỗi (thường là preflight OPTIONS)}.
  2. Compare Request Origin, method, and requested headers with Response Access-Control-Allow-* {So Request Origin, method, header yêu cầu với Response Access-Control-Allow-*}.
  3. Check whether credentials: 'include' requires an exact origin echo + Allow-Credentials: true {Kiểm tra credentials: 'include' có cần echo origin chính xác + Allow-Credentials: true không}.
  4. Remember: fix the API (or gateway) headers, not the SPA “turning off CORS” — there is no client-side switch that safely bypasses SOP for production traffic {Nhớ: sửa header API (hoặc gateway), không phải SPA “tắt CORS” — không có công tắc client an toàn bỏ qua SOP cho traffic production}.

Browser extensions and “CORS unblock” plugins train bad habits and do not represent real users {Extension trình duyệt và plugin “CORS unblock” dạy thói xấu và không đại diện user thật}. Reproduce in a clean profile {Tái hiện trên profile sạch}.


Frontend checklist {Checklist frontend}

Before you blame the framework {Trước khi đổ lỗi framework}:

  • Know whether the call is simple or preflighted — JSON POST is usually preflighted {Biết call đơn giản hay preflightPOST JSON thường cần preflight}.
  • Match credentials on the client to Allow-Credentials + exact origin on the server {Khớp credentials client với Allow-Credentials + origin chính xác server}.
  • Never ask backend to “reflect Origin” without an allowlist review {Đừng bảo backend “reflect Origin” mà không review allowlist}.
  • Keep CSRF defenses for cookie sessions even when CORS is strict {Giữ phòng CSRF cho phiên cookie dù CORS chặt}.
  • Treat CORS as documentation for browsers, authz as law for everyone {Coi CORS là hướng dẫn cho trình duyệt, authz là luật cho mọi client}.

Bài tập / Exercises

1. In one sentence: does CORS stop evil.com from sending a POST with the victim’s cookies to api.bank.com? Does it stop evil.com from reading the JSON response in JS? {Một câu: CORS có chặn evil.com gửi POST kèm cookie nạn nhân tới api.bank.com không? Có chặn evil.com đọc JSON phản hồi bằng JS không?}

Solution {Lời giải}

Send: No — the browser still delivers the request (CSRF territory, Part 4) {Gửi: Không — trình duyệt vẫn chuyển request (miền CSRF, Phần 4)}. Read: Yes, by default — unless api.bank.com sends CORS headers that include evil.com, JS cannot read the body {Đọc: Có, mặc định — trừ khi api.bank.com gửi header CORS cho phép evil.com, JS không đọc được body}.

2. Why does fetch('https://api.example.com/user', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }) from https://app.example.com almost always produce an OPTIONS request first? {Vì sao fetch đó từ https://app.example.com hầu như luôn sinh OPTIONS trước?}

Solution {Lời giải}

PATCH is not a simple method, application/json is not a simple Content-Type, and credentialed cross-origin requests need explicit allowance — the browser preflights before the real request {PATCH không phải method đơn giản, application/json không phải Content-Type đơn giản, request cross-origin có credentials cần cho phép rõ — trình duyệt preflight trước request thật}.

3. Spot the bug: the API sets Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. What should production use instead for a cookie session SPA at https://app.example.com? {Tìm lỗi: API đặt Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true. Production nên dùng gì cho SPA cookie tại https://app.example.com?}

Solution {Lời giải}

Browsers reject * with credentials {Trình duyệt từ chối * kèm credentials}. Use Access-Control-Allow-Origin: https://app.example.com (only when Origin matches allowlist), Access-Control-Allow-Credentials: true, and Vary: Origin {Dùng Access-Control-Allow-Origin: https://app.example.com (chỉ khi Origin khớp allowlist), Access-Control-Allow-Credentials: true, và Vary: Origin}.

Stretch {Nâng cao}: Audit this pseudo-middleware. List every issue and rewrite it safely {Audit pseudo-middleware này. Liệt kê mọi lỗi và viết lại an toàn}:

function cors(req: { headers: { origin?: string } }, res: { setHeader: (k: string, v: string) => void }) {
  const o = req.headers.origin;
  if (o === 'null' || o?.endsWith('.example.com')) {
    res.setHeader('Access-Control-Allow-Origin', o ?? 'null');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  res.setHeader('Access-Control-Allow-Origin', '*');
}
Solution {Lời giải}

Issues: reflects any subdomain of example.com (typosquat / tenant risk); allows null with credentials; sets * after credentialed echo (confusing / wrong); no Vary: Origin; no Allow-Methods / Allow-Headers for preflight {Lỗi: reflect mọi subdomain example.com; cho null kèm credentials; đặt * sau echo credentials; thiếu Vary: Origin; thiếu Allow-Methods / Allow-Headers cho preflight}. Safe approach: fixed Set of full origins, echo only on match, never * with credentials, handle OPTIONS explicitly {Cách an toàn: Set origin đầy đủ, chỉ echo khi khớp, không * với credentials, xử lý OPTIONS rõ ràng}.


Key takeaways {Điểm chính}

  • CORS relaxes SOP for reads — it does not protect your server from non-browser clients {CORS nới SOP để đọc — không bảo vệ server khỏi client không phải trình duyệt}.
  • The browser sends cross-origin requests; CORS controls whether your JS may read the response {Trình duyệt gửi request cross-origin; CORS quyết định JS của bạn có đọc phản hồi không}.
  • Simple vs preflightPUT/PATCH/DELETE, custom headers, and application/json trigger OPTIONS {Đơn giản vs preflightPUT/PATCH/DELETE, header tùy chỉnh, application/json kích hoạt OPTIONS}.
  • Credentials need exact origin echo + Allow-Credentials: true — never * {Credentials cần echo origin chính xác + Allow-Credentials: true — không *}.
  • Blind Origin reflection with credentials lets attacker origins read authenticated API data in the victim’s browser {Phản chiếu Origin mù kèm credentials cho origin kẻ tấn công đọc dữ liệu API đã xác thực trong trình duyệt nạn nhân}.
  • CORS ≠ CSRF defense — see Part 4; fix CORS errors on the server {CORS ≠ phòng CSRF — xem Phần 4; sửa lỗi CORS ở server}.

Next up {Tiếp theo}

Part 7 — Clickjacking & Framing: when attackers stack invisible iframes over your UI, and how X-Frame-Options / CSP frame-ancestors lock the page out of foreign frames {Phần 7 — Clickjacking & Framing: khi kẻ tấn công xếp iframe vô hình lên UI của bạn, và X-Frame-Options / CSP frame-ancestors khóa trang khỏi frame lạ}. Continue to Part 7 — Clickjacking & Framing.