Trung bình Bảo Mật

Hiểu CORS cho đúng: same-origin policy, preflight, credentials và config/cors.php của Laravel

Gặp lỗi CORS là muốn trả ngược Origin cho xong. Hiểu browser kiểm tra gì, khi nào có preflight, credentials đổi luật ra sao và cấu hình cho an toàn.

07 Th11, 2026 13 phút Cập nhật 23 Th10, 2026 6 Khối code
Sơ đồ
graph TD A[Script ở app.example.com gọi api.example.com] --> B{Là simple request?} B -->|đúng| C[Browser gửi request kèm header Origin] B -->|không| D[Browser gửi preflight OPTIONS] D --> E{Origin, method và header được phép?} E -->|không| F[Request thật không bao giờ được gửi] E -->|có| C C --> G{Response có Allow-Origin khớp?} G -->|có| H[Script đọc được response] G -->|không| I[Script chỉ thấy network error]

Dashboard React mới chạy ở app.example.com, gọi API Laravel ở api.example.com. Lúc dev local thì mọi thứ đi qua proxy của Vite nên chạy ngon. Lên staging thì console đỏ lòm: "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present". Có người dán một câu trả lời trên Stack Overflow, set Access-Control-Allow-Origin: *, rồi request login lại fail với một lỗi CORS khác vì nó gửi kèm cookie. Người khác "sửa" bằng cách trả ngược lại y chang cái Origin của request. Giờ thì chạy được, mà giờ cũng là lúc mọi website trên internet đọc được dữ liệu user của mình bằng chính cookie của họ.

CORS là kiểu chủ đề mà người ta cấu hình theo kiểu thử tới thử lui cho tới khi hết lỗi. Bỏ ra mười lăm phút để hiểu browser thật sự đang làm gì là đáng, vì mấy cách "làm cho hết lỗi" lại chính là mấy cách mở ra lỗ hổng.

Same-origin policy trước đã

Một origin là bộ ba scheme + host + port. https://app.example.com và https://api.example.com là hai origin khác nhau; http:// với https:// của cùng một host cũng khác, :443 với :8443 cũng khác.

Same-origin policy là luật của browser: JavaScript chạy trên một origin được phép gửi nhiều loại request tới origin khác, nhưng không được đọc response, trừ khi origin bên kia cho phép rõ ràng. Nó bảo vệ user chứ không phải bảo vệ server. Không có nó, trang nào mình ghé vô cũng gọi được fetch('https://your-bank.com/api/accounts', {credentials: 'include'}) rồi đọc kết quả với session cookie của mình gắn sẵn.

CORS (Cross-Origin Resource Sharing) là cách nới ra: một bộ response header để server nói "mấy origin này được đọc response của tôi, với mấy method và header này, có hoặc không có credentials". Có hai hệ quả người ta hay bỏ sót:

  • CORS do browser thực thi. curl, Postman, gọi server với server hay app mobile đều bỏ qua nó hoàn toàn. CORS không phải là xác thực, cũng không phải firewall cho API.
  • CORS là nới lỏng. Server không gửi header CORS nào là cấu hình chặt nhất. Mỗi header mình thêm vô là nới ra một chút.

Simple request và preflight

Browser chia request cross-origin làm hai nhóm.

Simple request là loại mà một form HTML đã gửi được từ trước khi có CORS: method GET, HEAD hoặc POST, chỉ có header nằm trong danh sách an toàn (như Accept, Accept-Language, Content-Language), và Content-Type là application/x-www-form-urlencoded, multipart/form-data hoặc text/plain. Browser gửi nó đi liền kèm header Origin, rồi mới quyết định JavaScript có được thấy response hay không.

Mọi thứ khác (PUT hay DELETE, Content-Type là JSON, có header Authorization, có header X- tự đặt) đều kích hoạt preflight: một request OPTIONS để xin phép trước. Chỉ khi câu trả lời cho phép method, header và origin thì browser mới gửi request thật.

graph TD A[Script ở app.example.com gọi api.example.com] --> B{Là simple request?} B -->|đúng| C[Browser gửi request kèm header Origin] B -->|không| D[Browser gửi preflight OPTIONS] D --> E{Origin, method và header được phép?} E -->|không| F[Request thật không bao giờ được gửi] E -->|có| C C --> G{Response có Allow-Origin khớp?} G -->|có| H[Script đọc được response] G -->|không| I[Script chỉ thấy network error]

Một preflight thật cho request POST JSON có bearer token:

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

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin

Hai chi tiết quan trọng. Một, simple request vẫn tới server và vẫn chạy, kể cả khi sau đó browser giấu response đi. CORS không bảo vệ một POST đổi trạng thái với content type của form; đó là chuyện của CSRF, cần CSRF token hoặc cookie SameSite. Hai, Access-Control-Max-Age cho browser cache câu trả lời preflight. Chromium giới hạn ở 7200 giây, Firefox ở 86400, đặt lớn hơn thì bị âm thầm hạ xuống.

Mặc định fetch() không gửi cookie khi cross-origin. Muốn gửi thì set credentials: 'include' (hoặc withCredentials: true bên axios):

// Runs on https://app.example.com
const res = await fetch('https://api.example.com/api/orders', {
  method: 'POST',
  credentials: 'include', // send cookies; requires Allow-Credentials and an explicit origin
  headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken },
  body: JSON.stringify({ sku: 'LAP-14', qty: 1 }),
});

Một khi có credentials, response của server phải thỏa luật chặt hơn, không thì browser không cho JavaScript thấy:

  • Phải có Access-Control-Allow-Credentials: true.
  • Access-Control-Allow-Origin phải là đúng origin, không bao giờ là *.
  • Với request có credentials, dấu * trong Allow-Headers, Allow-Methods và Expose-Headers bị hiểu là chuỗi bình thường chứ không phải wildcard. Liệt kê rõ ra.

Hễ giá trị Allow-Origin phụ thuộc vô request thì thêm Vary: Origin. Không thì CDN hay cache dùng chung có thể lưu response cho origin A rồi trả cho origin B, nhẹ thì B bị lỗi, nặng thì B được cấp quyền luôn.

Laravel 13: config/cors.php

Laravel có sẵn middleware HandleCors trong global stack, chạy bằng fruitcake/php-cors. Nó chỉ xử lý mấy path liệt kê trong config/cors.php, publish ra bằng php artisan config:publish cors. Cấu hình production cho một SPA first-party xài cookie auth trông như vầy:

// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],

    'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', ''))),

    // Anchored on both ends: preview deploys like https://pr-123.preview.example.com
    'allowed_origins_patterns' => ['#^https://pr-\d+\.preview\.example\.com$#'],

    'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],

    'exposed_headers' => ['X-RateLimit-Remaining'],

    'max_age' => 7200,

    'supports_credentials' => true,
];

Mỗi lựa chọn mang lại gì:

  • paths giới hạn CORS chỉ cho API và endpoint CSRF cookie của Sanctum. Route web và trang admin không có header CORS nào hết.
  • allowed_origins lấy từ biến env (CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com), nên staging với production khác nhau mà không cần sửa code.
  • allowed_origins_patterns là regex đầy đủ. Neo hai đầu bằng ^ và $, escape dấu chấm, không thì example.com.evil.io cũng khớp.
  • exposed_headers quyết định JavaScript đọc được thêm response header nào ngoài mấy header an toàn mặc định. Không có nó thì res.headers.get('X-RateLimit-Remaining') trả null dù tab network vẫn thấy header đó.
  • supports_credentials phải là true khi xài cookie auth (Sanctum chế độ SPA). Với Sanctum thì set thêm SANCTUM_STATEFUL_DOMAINS và SESSION_DOMAIN kiểu .example.com để session cookie dùng chung giữa các subdomain.

Coi chừng một mặc định: khi allowed_origins => ['*'] và supports_credentials => true, thư viện không gửi * (browser sẽ từ chối). Nó trả ngược lại origin của request. Browser vui vẻ, còn API của mình thì site nào cũng đọc được, kèm cookie. Đừng bao giờ kết hợp hai cái này.

Tự làm bằng tay trong Express

Khi không có helper của framework, logic gói gọn trong một middleware nhỏ. Phần quan trọng là tra allowlist, Vary: Origin, và trả lời preflight mà không chạy vô route handler:

const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const METHODS = 'GET, POST, PUT, PATCH, DELETE';
const HEADERS = 'content-type, authorization, x-xsrf-token';

function cors(req, res, next) {
  const origin = req.get('Origin');
  res.vary('Origin'); // caches must not mix responses for different origins

  if (origin && ALLOWED.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Access-Control-Allow-Credentials', 'true');
  }

  if (req.method === 'OPTIONS' && req.get('Access-Control-Request-Method')) {
    if (!origin || !ALLOWED.has(origin)) {
      return res.sendStatus(403);
    }
    res.set('Access-Control-Allow-Methods', METHODS);
    res.set('Access-Control-Allow-Headers', HEADERS);
    res.set('Access-Control-Max-Age', '7200');
    return res.sendStatus(204);
  }

  next();
}

module.exports = { cors };

Còn đây là mấy pattern nên grep khi review code, vì cái nào cũng từng lên production ở sản phẩm thật:

// All of these are real-world bugs. Do not ship them.
res.set('Access-Control-Allow-Origin', req.get('Origin'));        // reflects ANY origin
if (origin.endsWith('example.com')) allow(origin);                // matches evil-example.com
if (/https:\/\/.*\.example\.com/.test(origin)) allow(origin);     // unanchored: example.com.evil.io
if (origin === 'null' || ALLOWED.has(origin)) allow(origin);      // sandboxed iframes send null

Kiểm thử cấu hình CORS (test)

Bug CORS hiện ra trên browser, nhưng phía server thì mình khóa lại được bằng feature test bình thường. Test cả trường hợp chạy đúng lẫn trường hợp origin lạ không nhận được gì:

it('answers a preflight from an allowed origin', function () {
    config(['cors.allowed_origins' => ['https://app.example.com']]);

    $this->withHeaders([
        'Origin' => 'https://app.example.com',
        'Access-Control-Request-Method' => 'POST',
        'Access-Control-Request-Headers' => 'content-type, authorization',
    ])->options('/api/orders')
        ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com')
        ->assertHeader('Access-Control-Allow-Credentials', 'true')
        ->assertHeader('Access-Control-Max-Age', '7200');
});

it('gives no CORS headers to an unknown origin', function () {
    config(['cors.allowed_origins' => ['https://app.example.com']]);

    $this->withHeaders(['Origin' => 'https://app.example.com.evil.io'])
        ->getJson('/api/health')
        ->assertHeaderMissing('Access-Control-Allow-Origin');
});

it('never combines a wildcard with credentials', function () {
    $origins = config('cors.allowed_origins');

    expect(config('cors.supports_credentials') && in_array('*', $origins, true))->toBeFalse();
});

Test thứ ba là hàng rào rẻ tiền: build fail liền khi có ai set combo nguy hiểm wildcard cộng credentials. Muốn kiểm tra end-to-end thì chạy bộ Playwright của SPA với API staging trên domain thật, đừng đi qua proxy dev vì nó giấu CORS đi hoàn toàn.

Mấy lỗi hay gặp

  • Trả ngược header Origin. Tương đương * kèm credentials. Luôn kiểm tra với allowlist.
  • So khớp hậu tố hoặc regex không neo. endsWith('example.com') nhận luôn evil-example.com. So khớp đúng origin, hoặc pattern có neo.
  • Cho phép null. Iframe sandbox, trang file:// và vài kiểu redirect gửi Origin: null. Kẻ tấn công cố tình tạo ra nó được.
  • Quên Vary: Origin. Dẫn tới response bị cache với origin sai, nhìn giống lỗi CORS ngẫu nhiên sau CDN.
  • Lỗi CORS mà thật ra là 401, 413 hay 502. Nếu Nginx, load balancer hay worker PHP bị crash trả lời mà không có header CORS, browser sẽ báo lỗi CORS và giấu status thật. Coi tab network hoặc log server trước khi đụng tới config CORS.
  • Coi CORS là phân quyền. Nó chặn browser đọc response. Nó không chặn curl, không chặn một simple POST chạy, và không thay được xác thực hay chống CSRF.
  • Preflight đụng middleware auth. Request OPTIONS không có header Authorization. Nếu lớp auth chạy trước CORS và trả 401 thì preflight fail. Trong Laravel, HandleCors là global và chạy trước, cứ để nó ở đó.

Khi nào xài CORS (và khi nào không)

  • Xài khi JavaScript trên browser ở một origin thật sự cần gọi API ở origin khác: SPA ở app. gọi api., API public chỉ đọc cho site bên thứ ba (lúc này * không credentials là ổn), hoặc widget nhúng trên domain của khách với allowlist rõ ràng.
  • Tránh khi có thể phục vụ frontend và API chung một origin. Đặt API dưới https://example.com/api sau cùng reverse proxy (hoặc xài rewrites của Next.js) là bỏ được hết CORS, độ trễ preflight và mấy rắc rối domain cookie.
  • Đừng tìm tới nó cho gọi server với server, app mobile hay CLI. Mấy thứ đó không thực thi CORS, nên nó không giúp mà cũng không bảo vệ gì.
  • Giữ nó hẹp: path cụ thể, origin rõ ràng, header rõ ràng, và credentials chỉ bật ở chỗ thật sự xài cookie.

Nhớ một luật thôi: CORS là server nói với browser nó tin ai. Giữ danh sách đó ngắn, chính xác và nhàm chán.

Thử thách tương tác

Thử thách

Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.

Đề bài

Viết resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) bằng PHP và JavaScript. Hàm trả về các response header CORS dưới dạng mảng kết hợp / object. config có allowed_origins (danh sách origin chính xác, có thể chứa '*'), allowed_methods, allowed_headers (có thể chứa '*'), supports_credentials (bool) và max_age (int). Quy tắc: origin là null, hoặc không khớp chính xác trong allowed_origins mà cũng không có '*', thì trả kết quả rỗng. Access-Control-Allow-Origin chỉ là '*' khi allowed_origins có '*' và supports_credentials là false; còn lại thì là origin của request và thêm Vary: Origin. Nếu supports_credentials là true thì thêm Access-Control-Allow-Credentials: 'true'. Request là preflight khi method là 'OPTIONS' và requestMethod khác null: viết hoa requestMethod rồi trả rỗng nếu nó không nằm trong allowed_methods (không phân biệt hoa thường) và allowed_methods không có '*'; viết thường và trim từng header được yêu cầu, trả rỗng nếu có cái nào không nằm trong allowed_headers (không phân biệt hoa thường) trừ khi allowed_headers có '*'. Preflight hợp lệ thì thêm Access-Control-Allow-Methods (requestMethod đã viết hoa), Access-Control-Allow-Headers (các header đã viết thường nối bằng ', ', chỉ khi có header) và Access-Control-Max-Age (max_age dạng string).

Code khởi tạo

php
function resolveCors(array $config, string $method, ?string $origin, ?string $requestMethod = null, array $requestHeaders = []): array
{
    // TODO: no Origin, or an origin not allowed (exact match or '*') -> []
    // TODO: Allow-Origin is '*' only for a wildcard WITHOUT credentials,
    //       otherwise echo the origin and add Vary: Origin
    // TODO: credentials -> Access-Control-Allow-Credentials: true
    // TODO: preflight (OPTIONS + requestMethod): check method and headers,
    //       then add Allow-Methods, Allow-Headers and Max-Age
    return ['TODO' => 'TODO'];
}
javascript
function resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) {
  // TODO: no Origin, or an origin not allowed (exact match or '*') -> {}
  // TODO: Allow-Origin is '*' only for a wildcard WITHOUT credentials,
  //       otherwise echo the origin and add Vary: Origin
  // TODO: credentials -> Access-Control-Allow-Credentials: 'true'
  // TODO: preflight (OPTIONS + requestMethod): check method and headers,
  //       then add Allow-Methods, Allow-Headers and Max-Age
  throw new Error('TODO');
}

Lời giải của bạn

Gợi ý

Đã mở hết gợi ý
Gợi ý 1

So origin chính xác tuyệt đối: kiểm tra hậu tố hay chuỗi con sẽ nhận luôn https://app.example.com.evil.io

Gợi ý 2

Giá trị Allow-Origin phụ thuộc hai thứ: có wildcard không, và có bật credentials không

Gợi ý 3

Chuẩn hóa hoa thường một lần (method viết hoa, header viết thường) trước khi so danh sách

Lời giải · php
function resolveCors(array $config, string $method, ?string $origin, ?string $requestMethod = null, array $requestHeaders = []): array
{
    if ($origin === null) {
        return [];
    }

    $wildcard = in_array('*', $config['allowed_origins'], true);
    if (! $wildcard && ! in_array($origin, $config['allowed_origins'], true)) {
        return [];
    }

    $headers = [];
    if ($wildcard && ! $config['supports_credentials']) {
        $headers['Access-Control-Allow-Origin'] = '*';
    } else {
        $headers['Access-Control-Allow-Origin'] = $origin;
        $headers['Vary'] = 'Origin';
    }

    if ($config['supports_credentials']) {
        $headers['Access-Control-Allow-Credentials'] = 'true';
    }

    if ($method !== 'OPTIONS' || $requestMethod === null) {
        return $headers;
    }

    $requestMethod = strtoupper($requestMethod);
    $methods = array_map('strtoupper', $config['allowed_methods']);
    if (! in_array('*', $methods, true) && ! in_array($requestMethod, $methods, true)) {
        return [];
    }

    $requested = array_map(fn (string $h): string => strtolower(trim($h)), $requestHeaders);
    $allowed = array_map('strtolower', $config['allowed_headers']);
    if (! in_array('*', $allowed, true) && array_diff($requested, $allowed) !== []) {
        return [];
    }

    $headers['Access-Control-Allow-Methods'] = $requestMethod;
    if ($requested !== []) {
        $headers['Access-Control-Allow-Headers'] = implode(', ', $requested);
    }
    $headers['Access-Control-Max-Age'] = (string) $config['max_age'];

    return $headers;
}
Lời giải · javascript
function resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) {
  if (origin === null || origin === undefined) return {};

  const wildcard = config.allowed_origins.includes('*');
  if (!wildcard && !config.allowed_origins.includes(origin)) return {};

  const headers = {};
  if (wildcard && !config.supports_credentials) {
    headers['Access-Control-Allow-Origin'] = '*';
  } else {
    headers['Access-Control-Allow-Origin'] = origin;
    headers['Vary'] = 'Origin';
  }
  if (config.supports_credentials) {
    headers['Access-Control-Allow-Credentials'] = 'true';
  }

  if (method !== 'OPTIONS' || requestMethod === null || requestMethod === undefined) {
    return headers;
  }

  const reqMethod = requestMethod.toUpperCase();
  const methods = config.allowed_methods.map((m) => m.toUpperCase());
  if (!methods.includes('*') && !methods.includes(reqMethod)) return {};

  const requested = requestHeaders.map((h) => h.trim().toLowerCase());
  const allowed = config.allowed_headers.map((h) => h.toLowerCase());
  if (!allowed.includes('*') && requested.some((h) => !allowed.includes(h))) return {};

  headers['Access-Control-Allow-Methods'] = reqMethod;
  if (requested.length > 0) {
    headers['Access-Control-Allow-Headers'] = requested.join(', ');
  }
  headers['Access-Control-Max-Age'] = String(config.max_age);
  return headers;
}

Cùng chủ đề Bảo Mật