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:
| Layer | Who enforces it | What it does |
|---|---|---|
| SOP (default) | Browser | Blocks JS from reading cross-origin responses |
| CORS | Browser + server’s Access-Control-* headers | Opt-in to allow specific origins to read |
| Authn / authz | Your server | Decides 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.
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ặcPOST. - Chỉ header request trong safelist CORS (vd
Accept,Content-Typevới giá trị cho phép, …). - Nếu
POSTcóContent-Type, phải làapplication/x-www-form-urlencoded,multipart/form-data, hoặctext/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/jsontrênPOST.
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
| Header | Role |
|---|---|
Access-Control-Allow-Origin | Which origins may read the response in JS (echo a specific origin, not * when using credentials) |
Access-Control-Allow-Methods | Methods allowed after preflight |
Access-Control-Allow-Headers | Request headers allowed after preflight |
Access-Control-Allow-Credentials: true | Allows cookies / HTTP auth / client certs with credentials: 'include' |
Access-Control-Max-Age | How long the browser may cache preflight result (seconds) |
Access-Control-Expose-Headers | Which 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:
- Client:
credentials: 'include'(hoặcwithCredentials: truetrên XHR). - Server:
Access-Control-Allow-Credentials: true. - Server:
Access-Control-Allow-Originlà chuỗi origin chính xác — khô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 và 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ủ:
| Myth | Reality |
|---|---|
| ”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”:
- Mở Network → chọn request lỗi (thường là preflight
OPTIONS). - So Request
Origin, method, header yêu cầu với ResponseAccess-Control-Allow-*. - Kiểm tra
credentials: 'include'có cần echo origin chính xác +Allow-Credentials: truekhông. - 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 preflight —
POSTJSON thường cần preflight. - Khớp
credentialsclient vớiAllow-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: * và 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 preflight —
PUT/PATCH/DELETE, header tùy chỉnh,application/jsonkích hoạtOPTIONS. - 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ạ.