jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CORS Explained — Preflight, Simple Requests, and How to Actually Fix It

A bilingual deep-dive into CORS: the Same-Origin Policy, simple vs preflight requests, every Access-Control header, credentials mode, which requests bypass CORS checks, and how to solve CORS in real web apps.

12 MIN READ

Điều ai cũng hiểu sai

Trước hết, hãy khắc cốt điều này: CORS không chặn request của bạn được gửi đi. Nó chặn JavaScript của bạn đọc response.

Request khác origin thường vẫn tới server và chạy (cái POST của bạn thậm chí có thể tạo record!) — browser chỉ từ chối trả response lại cho code của bạn trừ khi server đồng ý bằng header đúng. Hiểu điều này xoá bỏ 90% sự bối rối.


Chính sách Same-Origin (SOP)

CORS tồn tại để nới lỏng Same-Origin Policy, một quy tắc bảo mật của browser cô lập các site khỏi nhau. Hai URL chung một origin chỉ khi cả ba khớp:

            scheme  ://  host           :  port
origin =    https   ://  app.example.com:  443

https://app.example.com/a   vs  https://app.example.com/b   → SAME origin {cùng}
https://app.example.com     vs  http://app.example.com      → different (scheme) {khác scheme}
https://app.example.com     vs  https://api.example.com     → different (host) {khác host}
https://app.example.com     vs  https://app.example.com:8080 → different (port) {khác port}

Không có CORS, script trên app.example.com không đọc được data từ api.example.com — dù cả hai đều của bạn. CORS là cơ chế server dùng để nói “origin kia được phép đọc response của tôi.”


Request đơn giản

Một số request được coi là “đơn giản” và được gửi trực tiếp, không preflight. Một request là đơn giản chỉ khi tất cả điều sau đúng:

  • MethodGET, HEAD, hoặc POST
  • Header chỉ giới hạn ở các header an toàn: Accept, Accept-Language, Content-Language, Content-Type (có giới hạn), và vài cái khác
  • Content-Type là một trong đúng ba giá trị:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • Không header tuỳ chỉnh (vd Authorization, X-Token)
  • Không body ReadableStream, không listener trên XMLHttpRequest.upload
// SIMPLE — sent directly {Đơn giản — gửi trực tiếp}
fetch("https://api.example.com/data"); // GET, no custom headers

// NOT simple → triggers preflight {Không đơn giản → kích hoạt preflight}
fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" }, // JSON is NOT one of the 3! {không thuộc 3 giá trị!}
  body: JSON.stringify({ x: 1 }),
});

Cái bẫy kinh điển: gửi JSON luôn luôn kích hoạt preflight, vì nó không phải Content-Type an toàn.

Ngay cả request đơn giản vẫn chịu CORS: server vẫn phải trả Access-Control-Allow-Origin nếu không browser chặn đọc response.


Request Preflight

Khi request không đơn giản, browser tự gửi một preflight trước — một request OPTIONS để xin phép trước request thật:

# 1. Browser sends preflight automatically {Browser tự gửi preflight}
OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

# 2. Server must approve {Server phải chấp thuận}
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

# 3. Only THEN does the browser send the real request {Chỉ SAU ĐÓ browser mới gửi request thật}
POST /data HTTP/1.1
Origin: https://app.example.com
Content-Type: application/json
...

Một preflight bị kích hoạt bởi: method không đơn giản, bất kỳ header tuỳ chỉnh, hoặc Content-Type không an toàn.

cho browser cache kết quả preflight để không phải hỏi lại mỗi request (bị browser giới hạn, vd ~2 tiếng ở Chrome).


Demo trực tiếp

Quy tắc CORS dễ thấm hơn khi bạn tự nghịch. Demo dưới là một Request Inspector: chọn method, Content-Type, header tuỳ chỉnh, credentials, và nó cho biết request là simple hay preflight, hiện OPTIONS được sinh ra, và liệt kê header server cần trả về. Cũng có một bypass checker cho <img>/<script>/no-cors.

Mở demo đầy đủ:


Các Header Response của CORS

Mọi điều khiển nằm ở header response của server:

HeaderMục đích
Access-Control-Allow-OriginOrigin nào được đọc response. Một origin cụ thể hoặc *
Access-Control-Allow-MethodsMethod cho phép (trong response preflight)
Access-Control-Allow-HeadersHeader request cho phép (preflight)
Access-Control-Allow-Credentialstrue để cho phép cookie/auth
Access-Control-Expose-HeadersHeader response nào JS được đọc (mặc định: chỉ một safelist)
Access-Control-Max-AgeCache preflight bao lâu

Đọc Header Response

Mặc định, JS chỉ đọc được một tập header response an toàn . Để đọc một header tuỳ chỉnh như X-Total-Count, server phải expose nó:

Access-Control-Expose-Headers: X-Total-Count, X-Request-Id

Mặc định, fetch/XHR khác origin không gửi cookie. Bạn phải bật lên:

fetch("https://api.example.com/me", {
  credentials: "include", // send cookies cross-origin {gửi cookie khác origin}
});

Khi có credentials, quy tắc chặt hơn:

# ❌ INVALID — wildcard is forbidden with credentials {cấm dùng wildcard với credentials}
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# ✅ VALID — must echo the EXACT origin {phải echo CHÍNH XÁC origin}
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Với credentials: "include":

  • không thể* — phải là origin chính xác.
  • bắt buộc.
  • cũng không thể dùng * (bị hiểu theo nghĩa đen).
  • Thêm Vary: Origin để cache không phục vụ response CORS của origin này cho origin khác.

Request nào bỏ qua kiểm tra CORS?

Đây là phần khiến nhiều người vấp. CORS quản lý việc đọc response trong script. Nhiều request khác origin diễn ra tự do — chúng chỉ không thể bị JS đọc.

1. Thẻ tải tài nguyên (vốn không đọc được)

Các thẻ này gửi request khác origin không CORS, vì browser dùng kết quả, không phải script của bạn:

<img src="https://other.com/pic.jpg" />      <!-- displays, JS can't read pixels {không đọc pixel} -->
<script src="https://cdn.com/lib.js"></script> <!-- executes {thực thi} -->
<link rel="stylesheet" href="https://cdn.com/app.css" />
<video src="https://other.com/clip.mp4"></video>
<iframe src="https://other.com/page"></iframe>  <!-- renders, JS can't read contents {không đọc nội dung} -->

Đây cũng là vì sao CDN, tracking pixel, và <form> POST xuyên site hoạt động không cần CORS — và vì sao chỉ CORS không chặn được CSRF.

2. Mode no-cors → Response mờ

const res = await fetch("https://other.com/data", { mode: "no-cors" });
// res.type === "opaque" — status 0, body unreadable {không đọc được}
console.log(res.status); // 0
await res.text();        // "" (empty) {rỗng}

Request vẫn đi ra, nhưng bạn nhận một response mờ không thể xem. Chỉ hữu ích cho “fire and forget” hoặc cache trong Service Worker.

3. Request cùng origin

Không CORS gì cả — đọc được hoàn toàn. Cách “sửa” CORS nhanh nhất là biến request thành same-origin (qua proxy).

Tóm tắt

Cross-origin request happens? {Request khác origin xảy ra?}   → almost always YES {hầu như LUÔN}
JS can READ the response?     {JS ĐỌC được response?}         → only if CORS allows it {chỉ khi CORS cho phép}

<img>/<script>/<link>/<iframe>  → request sent, response NOT readable by JS (no CORS needed)
fetch mode: "no-cors"           → request sent, opaque response (unreadable)
fetch mode: "cors" (default)    → response readable ONLY with proper CORS headers
same-origin                     → no CORS involved

Luồng quyết định của Browser

Gộp lại, browser chạy đại khái thuật toán này cho một fetch khác origin:

1. Is it same-origin?            → yes: read freely, no CORS {đọc tự do}
2. Is the request mode "no-cors"? → yes: send it, return an OPAQUE response {trả response mờ}
3. Is it a "simple" request?
     no  → send a PREFLIGHT (OPTIONS) first
            → server approves Methods/Headers? no → BLOCKED {chặn}
     yes → skip preflight
4. Send the actual request (with cookies if credentials:"include")
5. Does the response have Access-Control-Allow-Origin matching the origin?
     no → BLOCKED (CORS error) {chặn}
6. Credentials included?
     yes → need Allow-Credentials:true AND exact origin (not "*") → else BLOCKED
7. Otherwise → READ the response ✓ {đọc response}

Trực quan hoá

Sơ đồ luồng dưới đây làm sáng chính xác đường đi cho mọi tổ hợp request/server — bật/tắt same-origin, method, Content-Type, credentials, và header server để xem request kết thúc ở READ, BLOCKED, hay OPAQUE.

Mở demo luồng đầy đủ:

Các Mode của Fetch Request

Tuỳ chọn mode của request quyết định CORS áp dụng thế nào:

modeHành vi
cors mặc định)Cho phép khác origin nếu server gửi header CORS; response đọc được
no-corsCho phép khác origin nhưng response mờ (không đọc được)
same-originRequest khác origin thất bại hẳn
navigateBrowser dùng cho điều hướng cấp cao nhất (không phải fetch)

Cách thực sự sửa CORS

CORS được cấu hình trên server sở hữu tài nguyên. Frontend không tự sửa được (ngoài việc proxy).

Giải pháp 1: Set Header trên API Server

Express:

import cors from "cors";

app.use(
  cors({
    origin: "https://app.example.com", // not "*" if you use cookies {không dùng "*" nếu có cookie}
    methods: ["GET", "POST", "PUT", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,
    maxAge: 86400,
  })
);
// The cors middleware also answers OPTIONS preflights automatically
// {middleware cors cũng tự trả lời preflight OPTIONS}

nginx:

location /api/ {
  add_header Access-Control-Allow-Origin "https://app.example.com" always;
  add_header Access-Control-Allow-Credentials "true" always;
  add_header Vary "Origin" always;

  if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
    add_header Access-Control-Max-Age 86400 always;
    return 204; # answer preflight {trả lời preflight}
  }
}

Nếu hỗ trợ nhiều origin, đừng hardcode một cái — kiểm tra Origin đến với một allowlist rồi echo lại. Đừng bao giờ phản chiếu mọi origin một cách mù quáng khi có credentials.

Giải pháp 2: Proxy (Biến thành Same-Origin)

Nếu bạn không sửa được API server (API bên thứ ba), định tuyến request qua origin của bạn.

Dev (Vite proxy):

// vite.config.js — browser calls /api, Vite forwards it (no CORS in the browser)
// {browser gọi /api, Vite chuyển tiếp (không CORS ở browser)}
export default {
  server: {
    proxy: {
      "/api": { target: "https://api.thirdparty.com", changeOrigin: true },
    },
  },
};

Production (reverse proxy / BFF): phục vụ app và API dưới cùng origin (vd app.comapp.com/api sau nginx, hoặc một Backend-for-Frontend). CORS biến mất hoàn toàn vì request là same-origin.

Giải mã thông báo lỗi

"No 'Access-Control-Allow-Origin' header is present on the requested resource"
  → Server didn't send the header at all. {Server không gửi header.}

"The value of 'Access-Control-Allow-Origin' ... must not be the wildcard '*'
 when the request's credentials mode is 'include'"
  → You used credentials + "*". Echo the exact origin. {Dùng credentials + "*". Echo origin cụ thể.}

"Method PUT is not allowed by Access-Control-Allow-Methods in preflight response"
  → Add PUT to Access-Control-Allow-Methods. {Thêm PUT vào Allow-Methods.}

"Request header field authorization is not allowed by Access-Control-Allow-Headers"
  → Add Authorization to Access-Control-Allow-Headers. {Thêm Authorization vào Allow-Headers.}

Một dấu hiệu nhận biết: tab network hiện request thành công (200) nhưng console vẫn báo lỗi CORS → response về bình thường, browser chỉ chặn đọc nó.


Trường hợp đặc biệt & Header liên quan

CORS không sống một mình. Vài “hàng xóm” và trường hợp đặc biệt khiến nhiều người vấp.

CORS với Redirect

Một preflight (OPTIONS) không được redirect — browser sẽ làm nó thất bại. Với request thật, redirect tới origin khác phải tự pass CORS ở mỗi chặng, và Allow-Origin của response cuối vẫn phải khớp. Tránh redirect request CORS khi có thể.

Truy cập mạng riêng (PNA)

Một lớp bảo vệ mới hơn: một website công khai gọi tới mạng riêng/local (vd 192.168.x.x, localhost) kích hoạt một preflight đặc biệt:

Access-Control-Request-Private-Network: true
# server must answer:
Access-Control-Allow-Private-Network: true

Điều này ngăn site công khai độc hại “chọc” router hoặc dev server local của bạn.

CORP, COEP, COOP — Bộ ba Cross-Origin

Chúng tách biệt với CORS nhưng hay bị nhầm:

HeaderLàm gì
Cross-Origin-Resource-Policy (CORP)Cho một tài nguyên nói “ai được nhúng/tải tôi”
Cross-Origin-Embedder-Policy (COEP)Một document yêu cầu mọi subresource phải opt-in
Cross-Origin-Opener-Policy (COOP)Cô lập browsing context của bạn khỏi cửa sổ nó mở

Cùng nhau, COEP + COOP mở khoá cross-origin isolation, cần cho các tính năng mạnh như SharedArrayBuffer và timer độ phân giải cao.

ORB / CORB — Chặn response mờ

CORB (đang tiến hoá thành ORB) là một phòng thủ của browser chặn response khác origin của các kiểu nhạy cảm (HTML, JSON, XML) khỏi việc lọt vào một process nơi chúng có thể bị đọc qua side channel (Spectre). Nó tự động — chỉ cần đảm bảo API gửi đúng header Content-Type.

Thuộc tính crossorigin & Canvas Tainting

Khi bạn nạp ảnh khác origin vào <canvas>, canvas trở nên tainted (nhiễm)getImageData()/toDataURL() ném lỗi. Để đọc pixel, ảnh phải được phục vụ với CORS được yêu cầu với thuộc tính:

<img crossorigin="anonymous" src="https://cdn.com/pic.jpg" />
<!-- server must send Access-Control-Allow-Origin {server phải gửi Allow-Origin} -->

Tương tự với web font (@font-face cần CORS cho font khác origin) và <script crossorigin> để báo lỗi chi tiết.

WebSocket & Server-Sent Events

WebSocket không chịu CORS — chúng có mô hình origin riêng; server phải tự kiểm tra header Origin. Tuy nhiên EventSource (SSE) một fetch nên theo quy tắc CORS.

Lỗi mạng vs Lỗi CORS

Một CORS read thất bại hiện ra dưới dạng TypeError: Failed to fetch chung chung — JS không phân biệt được “bị CORS chặn” với “mạng hỏng” vì lý do bảo mật. Hãy xem tab Network + console: nếu request hiện 200 nhưng console báo CORS, đó là lỗi header, không phải kết nối.

Timing-Allow-Origin

Mặc định, Resource Timing API giấu thông tin timing chi tiết của tài nguyên khác origin. Để hiển thị chúng, tài nguyên gửi Timing-Allow-Origin: https://app.example.com (hoặc *).


Lỗi & Bảo mật

LỗiSửa
* + * + credentialsEcho origin chính xác
Preflight trả 4xx/redirectOPTIONS phải trả 2xx kèm header; không redirect
Header response tuỳ chỉnh không đọc đượcThêm vào Expose-Headers
Cache phục vụ sai header CORSGửi Vary: Origin
Phản chiếu mọi OriginKiểm tra với allowlist — = rò rỉ dữ liệu
Nghĩ CORS chặn CSRFKhông — dùng cookie SameSite + token CSRF

Lưu ý bảo mật: CORS không phải kiểm soát truy cập phía server. Nó chỉ ràng buộc browser. Client không phải browser (curl, server) bỏ qua hoàn toàn. Luôn thực thi authn/authz thật trên server.


Tham khảo nhanh

Mental model {Mô hình tư duy}:
  CORS blocks READING the response, not SENDING the request.

Simple request (no preflight) {Request đơn giản (không preflight)}:
  - GET/HEAD/POST
  - only safelisted headers
  - Content-Type ∈ {x-www-form-urlencoded, multipart/form-data, text/plain}

Triggers preflight (OPTIONS) {Kích hoạt preflight}:
  - PUT/PATCH/DELETE, custom headers, or Content-Type: application/json

Server response headers {Header response server}:
  Access-Control-Allow-Origin       (exact origin or *)
  Access-Control-Allow-Methods      (preflight)
  Access-Control-Allow-Headers      (preflight)
  Access-Control-Allow-Credentials  (true → no "*" allowed)
  Access-Control-Expose-Headers     (read custom response headers)
  Access-Control-Max-Age            (cache preflight)
  Vary: Origin                      (when echoing origin)

Bypass / not read-checked {Bypass / không kiểm tra đọc}:
  <img>/<script>/<link>/<iframe>, fetch mode "no-cors" (opaque), same-origin

Fixes {Cách sửa}:
  1. Set Access-Control-* on the API server
  2. Proxy → make it same-origin (dev proxy / reverse proxy / BFF)

CORS có vẻ “thù địch” cho đến khi mô hình “thông”: nó là browser bảo vệ người dùng, và server giữ chìa khoá. Đặt đúng header — hoặc đưa request về same-origin — và nó tan biến.