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.

Phần 6/10 trong series Web Security for Frontend Devs. Trước: Tiếp:

Đâ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. 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) 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. 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 đó.


Hiểu lầm số 1

CORS không bảo vệ server khỏi kẻ tấn công.

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. 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 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.

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

Mô hình tư duy: CORS là quyền đọc cho JS trình duyệt, không phải tường lửa API. 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.


Ôn SOP: gửi vs đọc

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

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

Request đơn giản vs cần 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.

Request cross-origin đơn giản chỉ khi tất cả điều kiện sau đúng:

  • Phương thức là GET, HEAD, hoặc POST.
  • Chỉ header request trong safelist CORS (vd Accept, Content-Type với giá trị cho phép, …).
  • Nếu POSTContent-Type, phải là application/x-www-form-urlencoded, multipart/form-data, hoặc text/plain.

Cần preflight khi vượt bất kỳ ranh giới nào — kích hoạt thường gặp:

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

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 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

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.


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 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 và CORS

Khi client gửi cookie hoặc HTTP auth, ba quy tắc phải khớp:

  1. Client: credentials: 'include' (hoặc withCredentials: true trên XHR).
  2. Server: Access-Control-Allow-Credentials: true.
  3. 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',
  },
});

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. Phiên vẫn có thể đổi phía server; SPA chỉ không đọc được bằng chứng.


Cấu hình nguy hiểm

Đây là lỗ hổng thật, không phải góp ý phong cách.

Phản chiếu Origin mù kèm credentials

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');
}

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.

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');
  }
}

* trên API nội bộ

Endpoint công khai chỉ đọc đôi khi dùng * không kèm credentials. Đặ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. Khóa API nội bộ bằng VPN / mTLS / chính sách mạng CORS chặt.

Tin origin null

iframe sandbox, file://, một số redirect gửi Origin: null. Cho phép null hầu như luôn sai với API có credentials.

Allowlist quá rộng

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. Ưu tiên tập hợp rõ ràng.


CORS KHÔNG làm gì

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

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. App dựa cookie vẫn cần SameSite + token chống CSRF.


Debug CORS trong DevTools

Khi fetch fail với “blocked by CORS policy”:

  1. Mở Network → chọn request lỗi (thường là preflight OPTIONS).
  2. So Request Origin, method, header yêu cầu với Response Access-Control-Allow-*.
  3. Kiểm tra credentials: 'include' có cần echo origin chính xác + Allow-Credentials: true không.
  4. 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.

Extension trình duyệt và plugin “CORS unblock” dạy thói xấu và không đại diện user thật. Tái hiện trên profile sạch.


Checklist frontend

Trước khi đổ lỗi framework:

  • Biết call đơn giản hay preflightPOST JSON thường cần preflight.
  • Khớp credentials client với Allow-Credentials + origin chính xác server.
  • Đừng bảo backend “reflect Origin” mà không review allowlist.
  • Giữ phòng CSRF cho phiên cookie dù CORS chặt.
  • 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. 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?

Lời giải

Gửi: Không — trình duyệt vẫn chuyển request (miền CSRF, Phần 4). Đọ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. Vì sao fetch đó từ https://app.example.com hầu như luôn sinh OPTIONS trước?

Lời giải

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. 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?

Lời giải

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

Nâng cao: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', '*');
}
Lời giải

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. Cách an toàn: Set origin đầy đủ, chỉ echo khi khớp, không * với credentials, xử lý OPTIONS rõ ràng.


Điểm chính

  • CORS nới SOP để đọc — không bảo vệ server khỏi client không phải trình duyệt.
  • 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.
  • Đơn giản vs preflightPUT/PATCH/DELETE, header tùy chỉnh, application/json kích hoạt OPTIONS.
  • Credentials cần echo origin chính xác + Allow-Credentials: true — không *.
  • 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 ≠ phòng CSRF — xem Phần 4; sửa lỗi CORS ở server.

Tiếp theo

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ạ.