SecLab
Client-sideĐầy đủ

CORS misconfiguration

V3A02
01

Là gì

CORS là cơ chế cho phép một trang web đọc response từ một origin khác — nó nới lỏng same-origin policy, không thắt chặt nó. Cấu hình sai CORS nghĩa là bạn đã cho phép một trang của kẻ tấn công đọc dữ liệu của người dùng bạn, bằng chính phiên đăng nhập của họ.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Điều quan trọng nhất phải nắm: CORS không bảo vệ server của bạn — nó bảo vệ người dùng của bạn, và mọi cấu hình CORS là một quyết định bỏ đi một phần bảo vệ đó. Nên câu hỏi đúng khi đọc một CORS policy không phải "nó có chặn gì không" mà "nó đang cho phép ai đọc dữ liệu của tôi".

Hai lầm tưởng phổ biến, và cả hai đều dẫn tới cùng một lỗ hổng:

  • "CORS chặn kẻ tấn công gọi API của tôi"không. Kẻ tấn công gọi API của bạn bằng curl thoải mái; CORS chỉ tồn tại trong trình duyệt. Nó quyết định một trang web đọc được response hay không.
  • "`Access-Control-Allow-Origin: là nguy hiểm nhất"* — không phải. ` không thể* dùng cùng credentials, nên nó không rò được dữ liệu cần đăng nhập. Cấu hình nguy hiểm nhất là phản chiếu Origin cộng Allow-Credentials: true: nó cho mọi origin đọc dữ liệu đã xác thực.

Và chỗ lỗi thật sự sống: các phép so Origin gần đúng. Không ai viết allowedOrigins = ["*"] với credentials. Người ta viết origin.EndsWith(".example.com") — và evil-example.com khớp, hoặc attacker.com#.example.com khớp, hoặc họ dùng StartsWithhttps://app.example.com.evil.com khớp. Bốn dòng code, bốn cách sai, và tất cả đều là một dòng regex hay một dòng Contains.

03

Cơ chế hoạt động

Cơ chế: trình duyệt vẫn gửi requestvẫn kèm cookie; nó chỉ quyết định có cho JavaScript đọc response hay không, dựa trên header mà server trả về.

Nguồn sơ đồ
sequenceDiagram    autonumber    actor V as Nạn nhân (đã đăng nhập app)    participant E as evil.example    participant A as api.app.example    V->>E: Mở trang của kẻ tấn công    E-->>V: fetch(api, #quot;credentials: include#quot;)    V->>A: GET /api/me<br/>Origin: https://evil.example<br/>Cookie: session=...    Note over A: Server PHẢN CHIẾU Origin<br/>và bật Allow-Credentials.    A-->>V: 200<br/>Access-Control-Allow-Origin: https://evil.example<br/>Access-Control-Allow-Credentials: true    Note over V: Trình duyệt thấy header khớp<br/>→ CHO JavaScript đọc response.    V->>E: Gửi dữ liệu đã đọc về cho kẻ tấn công

Điểm cốt lõi ở bước ③: request đã tới server và đã có cookie trước khi CORS có ý nghĩa gì. Nên CORS không bao giờ là một biện pháp phân quyền — nó chỉ quyết định ai đọc được kết quả.

Bảng phép so Origin sai — đây là bảng quan trọng nhất của trang, và mỗi hàng là một CVE thật:

CodeOrigin đi quaVì sao
o.EndsWith(".example.com")https://evil-example.comThiếu dấu chấm ở ranh giới
o.EndsWith("example.com")https://notexample.comCùng lỗi, rõ hơn
o.StartsWith("https://app.example.com")https://app.example.com.evil.comKhông neo cuối
o.Contains("example.com")https://evil.com/?x=example.comKhông neo hai đầu
Regex("^https://.*\.example\.com$")https://a.b.example.comCho phép mọi subdomain — kể cả subdomain người dùng tạo được
Phản chiếu Origin không kiểmmọi originĐây là dạng phổ biến nhất trong thực tế
Allowlist có nullnull (từ iframe sandbox, data:)Origin: null đến từ một iframe kẻ tấn công điều khiển

Ba chi tiết mà bản vá thường bỏ:

  • Vary: Originbắt buộc khi phản chiếu hoặc chọn theo origin. Không có nó, một CDN hay reverse proxy cache response kèm Allow-Origin của người dùng trước rồi trả cho người sau — biến một cấu hình đúng thành một lỗ.
  • Preflight cũng phải kiểm. Một OPTIONS trả Allow-Origin: * trong khi GET kiểm đúng vẫn là lỗ, vì preflight là chỗ trình duyệt hỏi trước.
  • http://localhost trong allowlist ở production. Kẻ tấn công không dùng được nó trực tiếp, nhưng một app desktop hay một malware chạy local thì dùng được.

Mô tả sơ đồ: Sơ đồ tuần tự: nạn nhân đang đăng nhập app mở một trang trên evil.example. Trang đó gọi fetch tới API với credentials include, nên trình duyệt gửi request kèm cookie phiên và header Origin là evil.example. Server phản chiếu lại Origin đó vào Access-Control-Allow-Origin và bật Access-Control-Allow-Credentials. Trình duyệt thấy hai header khớp nên cho JavaScript đọc response, và trang của kẻ tấn công gửi dữ liệu đã đọc được về máy chủ của họ.

04

Ví dụ cụ thể

Cùng một API, ba cấu hình. Chỉ cấu hình thứ ba an toàn.

Shell
# ① Dò bằng một Origin bịa hoàn toàn. Nếu nó được phản chiếu lại thì xong.curl -si https://api.app.example/api/me \  -H 'Origin: https://evil.example' -H 'Cookie: session=…' | grep -i 'access-control'
HTTP
Access-Control-Allow-Origin: https://evil.example      ← phản chiếuAccess-Control-Allow-Credentials: true                  ← và cho kèm cookie(không có Vary: Origin)

Hai dòng đó cùng nhau nghĩa là mọi trang web trên Internet đọc được dữ liệu người dùng của bạn.

Shell
# ② Bản vá "kiểm domain" — và bảng ở khối 3 cho thấy nó không đủ.for o in https://evil-example.com \         https://app.example.com.evil.com \         https://a.b.example.com \         null; do  echo -n "$o → "  curl -si https://api.app.example/api/me -H "Origin: $o" \    | grep -i '^access-control-allow-origin' || echo "(bị chặn)"done
https://evil-example.com        → Access-Control-Allow-Origin: https://evil-example.comhttps://app.example.com.evil.com → (bị chặn)https://a.b.example.com        → Access-Control-Allow-Origin: https://a.b.example.comnull                           → Access-Control-Allow-Origin: null

Ba trong bốn đi qua. EndsWith(".example.com") thiếu ranh giới, và null nằm trong allowlist.

Shell
# ③ Sau khi vá: so bằng, và Vary: Origin có mặt.curl -si https://api.app.example/api/me -H 'Origin: https://evil.example' | grep -iE 'access-control|vary'# (không có Access-Control-Allow-Origin nào — origin không trong allowlist)curl -si https://api.app.example/api/me -H 'Origin: https://app.example.com' | grep -iE 'access-control|vary'# Access-Control-Allow-Origin: https://app.example.com# Access-Control-Allow-Credentials: true# Vary: Origin
C#Ba cấu hình sai, và cả ba đều xuất hiện vì một nhu cầu phát triển hợp lý.
// ── ① Phản chiếu Origin. Dạng phổ biến nhất, và là lỗ hổng đầy đủ ───────────builder.Services.AddCors(o => o.AddPolicy("api", p => p    // ❌ SetIsOriginAllowed(_ => true) là "phản chiếu mọi origin". Cộng    //    AllowCredentials() nghĩa là MỌI trang web trên Internet đọc được dữ liệu    //    đã xác thực của người dùng bạn, bằng chính phiên của họ.    //    //    Người ta viết dòng này để SPA ở localhost gọi được API staging.    .SetIsOriginAllowed(_ => true)    .AllowAnyHeader()    .AllowAnyMethod()    .AllowCredentials())); // ── ② "Kiểm domain" — và bảng ở khối 3 cho thấy nó không đủ ────────────────builder.Services.AddCors(o => o.AddPolicy("api", p => p    .SetIsOriginAllowed(origin =>        // ❌ Thiếu ranh giới: "https://evil-example.com".EndsWith(".example.com")        //    là FALSE... nhưng "https://evil.example.com" là TRUE, và bất kỳ ai        //    tạo được một subdomain đều đọc được dữ liệu. Với dịch vụ có        //    "*.example.com" cho khách hàng thì đây là lỗ hổng ngay.        origin.EndsWith(".example.com", StringComparison.OrdinalIgnoreCase)        || origin == "null")            // ❌ null tới từ iframe sandbox    .AllowCredentials())); // ── ③ Tự viết middleware — và nó thiếu Vary ────────────────────────────────app.Use(async (ctx, next) =>{    var origin = ctx.Request.Headers.Origin.FirstOrDefault();    if (origin is not null && Allowed.Contains(origin))    {        ctx.Response.Headers["Access-Control-Allow-Origin"] = origin;        ctx.Response.Headers["Access-Control-Allow-Credentials"] = "true";        // ❌ Thiếu Vary: Origin. Allowlist ĐÚNG, phép so ĐÚNG, và vẫn rò: CDN ở        //    trước cache response này (kèm Allow-Origin của app.example.com) rồi        //    trả nó cho một request từ origin khác. Không test nào ở tầng ứng dụng        //    thấy được, vì lỗi chỉ tồn tại khi có cache trung gian.    }    await next();});
TypeScriptCùng lỗi trong Express: `origin: true` nghĩa là "phản chiếu bất cứ gì tới".
import cors from "cors"; app.use(cors({  // ❌ origin: true KHÔNG phải "cho phép origin của tôi" — nó là "phản chiếu Origin  //    của request". Cộng credentials: true nghĩa là mọi trang web đọc được dữ liệu  //    đã xác thực. Đây là hai dòng, và chúng là một lỗ hổng đầy đủ.  origin: true,  credentials: true,})); // Và biến thể "kiểm domain" cũng sai theo đúng bảng ở khối 3:app.use(cors({  origin: (o, cb) => cb(null, !!o && o.endsWith(".example.com")),  credentials: true,}));
05

Chuyện đã xảy ra

Một họ lỗi trong bug bounty, không phải một sự cố lớn duy nhất. CORS misconfiguration là một trong những hạng mục được báo cáo nhiều nhất trên HackerOne và Bugcrowd, với cùng một hình dạng lặp lại: phản chiếu Origin cộng Allow-Credentials: true trên một API nội bộ hoặc một subdomain ít ai để ý. Hậu quả điển hình là đọc được toàn bộ profile, token, và đôi khi cả khoá API của bất kỳ người dùng nào truy cập một trang do kẻ tấn công kiểm soát.

CVE-2021-3172 (Ansible Tower) và họ lỗ hổng cùng dạng trong framework. Nhiều framework và thư viện CORS từng có mặc định hoặc tuỳ chọn "cho mọi origin kèm credentials" — và người dùng bật nó vì nó là cách nhanh nhất để làm SPA chạy được ở local. Đáng nhớ vì nó cho thấy nguyên nhân thật: cấu hình sai bắt đầu từ nhu cầu phát triển, không từ sự bất cẩn.

Và một dạng khác đáng biết: Vary: Origin thiếu cộng một CDN ở trước. Đây là một lớp lỗi mà cả CORS policy lẫn code đều đúng — CDN cache response kèm Allow-Origin của một origin rồi phục vụ nó cho origin khác. Nó chỉ xuất hiện ở production, sau khi có CDN, và không test nào ở tầng ứng dụng thấy được.

06

Cách phòng chống

Lớp 1

Allowlist so BẰNG chuỗi tuyệt đối — không regex, không EndsWith

bắt buộc

Bảng ở khối 3 là toàn bộ lập luận: mọi phép so gần đúng đều có một origin đi qua. Cách duy nhất không có "gần đúng" là so bằng với một tập hữu hạn.

C#
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal){    "https://app.example.com",    "https://admin.example.com",};

StringComparer.Ordinal, không OrdinalIgnoreCase: origin phân biệt chữ hoa thường theo chuẩn, và so không phân biệt mở thêm một bề mặt không cần thiết.

Ba điều bắt buộc đi kèm:

  • Cả scheme và cả cổng trong chuỗi. example.com không phải một origin; https://example.com mới là. Thiếu scheme thì http://app.example.com đi qua và dữ liệu đi trên đường không mã hoá.
  • Không có null trong allowlist. Origin: null tới từ một iframe sandbox hoặc một data: URL — tức là từ một trang kẻ tấn công điều khiển hoàn toàn.
  • Không có localhost trong cấu hình production. Nó thuộc file cấu hình Development, và sự khác nhau đó phải cưỡng chế được bằng test (xem khối 7).

Trong ASP.NET Core: WithOrigins(...) làm đúng phép so bằng. Không dùng SetIsOriginAllowed(_ => true) — đó là phản chiếu, và nó là dạng phổ biến nhất của lỗi này.

C# · Layer 1So bằng với tập hữu hạn, cấu hình theo môi trường, Vary tự động, và preflight hẹp.
/// <summary>/// Allowlist so BẰNG. Bảng ở khối 3 là toàn bộ lập luận: mọi phép so gần đúng/// (EndsWith, StartsWith, Contains, regex) đều có một origin đi qua, và cách duy/// nhất không có "gần đúng" là một tập hữu hạn với phép so bằng.////// Đọc từ cấu hình theo MÔI TRƯỜNG, không hardcode: nguyên nhân thật của phần lớn/// trường hợp là allowlist của Development rò sang production, và cách chặn nó là/// làm cho hai file cấu hình khác nhau — cộng một test khẳng định điều đó./// </summary>public static class CorsSetup{    public const string PolicyName = "seclab-api";     public static IServiceCollection AddSecLabCors(        this IServiceCollection services, IConfiguration config, IHostEnvironment env)    {        // appsettings.Production.json: chỉ hai origin thật, đầy đủ scheme.        // appsettings.Development.json: thêm http://localhost:3100 và :3200.        var origins = config.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];         if (origins.Length == 0)            throw new InvalidOperationException(                "Cors:AllowedOrigins rỗng. Fail nhanh và rõ tốt hơn là im lặng không có CORS " +                "rồi ai đó 'sửa' bằng SetIsOriginAllowed(_ => true).");         // Fail nhanh nếu cấu hình sai hình dạng: một origin thiếu scheme nghĩa là        // http:// đi qua, và dữ liệu đi trên đường không mã hoá.        foreach (var o in origins)        {            if (!Uri.TryCreate(o, UriKind.Absolute, out var u) || u.PathAndQuery != "/")                throw new InvalidOperationException($"Origin không hợp lệ (cần scheme, không path): {o}");            if (!env.IsDevelopment() && u.Scheme != Uri.UriSchemeHttps)                throw new InvalidOperationException($"Origin không phải https ở production: {o}");            if (!env.IsDevelopment() && u.IsLoopback)                throw new InvalidOperationException($"localhost trong allowlist production: {o}");        }         return services.AddCors(opts => opts.AddPolicy(PolicyName, p => p            // WithOrigins so BẰNG chuỗi (ordinal), và ASP.NET Core tự thêm            // Vary: Origin — đó là lý do dùng nó thay vì tự viết middleware.            .WithOrigins(origins)             // Hẹp, không AllowAnyHeader/AllowAnyMethod: một origin trong allowlist            // vẫn không cần quyền gọi mọi method với mọi header.            .WithMethods("GET", "POST", "PATCH", "DELETE")            .WithHeaders("Content-Type", "Authorization", "X-CSRF-Token")             // Chỉ expose header client THẬT SỰ cần đọc. Mặc định trình duyệt cho            // đọc bảy header an toàn; mỗi cái thêm vào là một quyết định.            .WithExposedHeaders("X-Request-Id")             .AllowCredentials()             // 600s, không phải 86400: một policy đã sửa còn được trình duyệt dùng            // theo bản cũ trong đúng 10 phút, không phải một ngày.            .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));    }} // ── Lớp 3 · phát hiện Origin lạ ────────────────────────────────────────────/// <summary>/// Client hợp lệ gửi một tập Origin hữu hạn và gần như không đổi, nên một Origin/// ngoài allowlist là tín hiệu sạch hơn hầu hết mọi thứ trong SIEM.////// Đặt SAU UseCors: lúc này ta biết CORS đã quyết định gì, và ta chỉ ghi lại./// </summary>public sealed class CorsAuditMiddleware(RequestDelegate next, ILogger<CorsAuditMiddleware> log,    IOptionsMonitor<CorsOptions> _){    public async Task InvokeAsync(HttpContext ctx, IConfiguration config)    {        var origin = ctx.Request.Headers.Origin.FirstOrDefault();        var allowed = config.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];         if (origin is not null && !allowed.Contains(origin, StringComparer.Ordinal))            // "null" đáng chú ý riêng: nó tới từ iframe sandbox và data: URL, nên            // nó gần như luôn là một lần thử, không phải một client thật cấu hình sai.            log.LogWarning("Request với Origin ngoài allowlist: {Origin} → {Path}",                origin, ctx.Request.Path);         await next(ctx);    }} // Program.cs — thứ tự quan trọng: UseCors phải TRƯỚC UseAuthorization.app.UseCors(CorsSetup.PolicyName);app.UseMiddleware<CorsAuditMiddleware>();app.UseAuthentication();app.UseAuthorization();
TypeScript · Layer 1Set hữu hạn, so bằng, và `Vary: Origin` đặt tay vì `cors` không luôn thêm.
import cors from "cors"; // Đọc từ env theo MÔI TRƯỜNG. Không hardcode, và không có localhost trong production —// cấu hình dev rò sang là nguyên nhân thật của phần lớn trường hợp.const ALLOWED = new Set(  (process.env.CORS_ALLOWED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean),); if (ALLOWED.size === 0) {  // Fail nhanh và rõ. Im lặng không có CORS dẫn tới việc ai đó "sửa" bằng origin: true.  throw new Error("CORS_ALLOWED_ORIGINS rỗng");} if (process.env.NODE_ENV === "production") {  for (const o of ALLOWED) {    const u = new URL(o);    if (u.protocol !== "https:") throw new Error("origin không phải https: " + o);    if (["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))      throw new Error("localhost trong allowlist production: " + o);  }} app.use(cors({  // Set.has là phép so BẰNG. Không endsWith, không regex — bảng ở khối 3 cho thấy  // mọi phép so gần đúng đều có một origin đi qua.  //  // Callback trả false (không phải Error): request vẫn đi tiếp, chỉ là không có  // header cấp phép. CORS không chặn request, nó chỉ không cho đọc response.  origin: (origin, cb) => cb(null, origin !== undefined && ALLOWED.has(origin)),  credentials: true,  methods: ["GET", "POST", "PATCH", "DELETE"],  allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],  exposedHeaders: ["X-Request-Id"],  maxAge: 600,})); // Vary: Origin đặt TAY. Package cors thêm nó khi origin là một hàm, nhưng không phải// ở mọi đường — và thiếu nó thì một CDN ở trước cache response kèm Allow-Origin của// origin này rồi trả cho origin khác. Đặt vô điều kiện: nó rẻ và nó không sai bao giờ.app.use((req, res, next) => {  res.setHeader("Vary", "Origin");   // Lớp 3 — Origin lạ là tín hiệu độ nhiễu rất thấp: client hợp lệ gửi một tập  // hữu hạn và gần như không đổi.  const origin = req.get("origin");  if (origin && !ALLOWED.has(origin)) {    req.log.warn({ origin, path: req.path }, "request với Origin ngoài allowlist");  }  next();});
Lớp 1b

`Vary: Origin` bất cứ khi nào response phụ thuộc `Origin`

bắt buộc

Đây là biện pháp mà cả CORS policy đúng lẫn code đúng vẫn có thể thiếu, và nó biến một cấu hình an toàn thành một lỗ khi có CDN ở trước.

Cơ chế: nếu response mang Access-Control-Allow-Origin: https://app.example.com mà không có Vary: Origin, thì mọi cache trung gian (CDN, reverse proxy, thậm chí cache của trình duyệt dùng chung) coi response đó là một response cho URL đó. Người tiếp theo — từ origin khác — nhận lại nó kèm header cho phép origin cũ. Ngược lại còn tệ hơn: response cho một origin được allowlist bị cache rồi phục vụ cho một request từ origin của kẻ tấn công.

Vary: Origin nói cho cache biết "response này khác nhau theo header Origin", nên nó cache riêng từng origin.

ASP.NET Core thêm nó tự động khi dùng WithOrigins. Nhưng tự viết middleware CORS thì không — và tự viết là chuyện thường xảy ra khi cần logic riêng. Phép kiểm ở khối 7 kiểm đúng chỗ này.

Và nếu có CDN: kiểm cả ở tầng CDN rằng Origin nằm trong cache key, không chỉ tin Vary.

Lớp 1c

Đừng cần CORS: cùng origin, hoặc token thay cookie

Biện pháp mạnh nhất là loại bỏ bài toán, và nó thường khả thi hơn người ta nghĩ.

  • Đặt API và web cùng origin. app.example.com/api/* qua một reverse proxy thay vì api.example.com. Không có request cross-origin nào thì không có CORS policy nào để cấu hình sai. Đây cũng là cách SecLab làm: lib/api.ts gọi từ server component, nên trình duyệt không bao giờ thấy một request cross-origin.
  • Nếu buộc phải khác origin: dùng Authorization: Bearer, không dùng cookie. Token phải được JavaScript đặt vào header, nên một trang khác origin không tự động kèm được nó — khác hoàn toàn với cookie. Lúc đó Allow-Credentials không cần bật, và cả họ lỗ hổng này biến mất.

Đánh đổi phải nói rõ: token trong localStorage thì đọc được bằng XSS, còn cookie HttpOnly thì không (xem topic xss lớp 3). Nên lựa chọn này là đổi một họ rủi ro lấy một họ khác, không phải một cải thiện thuần. Cùng origin cộng cookie HttpOnly là phương án tốt nhất khi làm được.

Lớp 2

Thu hẹp những gì cho phép, kể cả khi origin đã đúng

Origin đúng không có nghĩa là cho phép tất cả. Bốn thứ nên hẹp:

  • Access-Control-Allow-Methods: chỉ những method API thật dùng. * là thừa và nó cho phép cả DELETE trên endpoint chỉ đọc.
  • Access-Control-Allow-Headers: liệt kê tường minh. * cho phép mọi header, kể cả những header mà một cấu hình proxy nào đó tin.
  • Access-Control-Expose-Headers: mặc định trình duyệt chỉ cho JS đọc bảy header an toàn. Mỗi header bạn expose là một quyết định — đừng expose header chứa thông tin nội bộ.
  • Access-Control-Max-Age: đặt vừa phải (600s). Quá dài thì một policy đã sửa vẫn được trình duyệt dùng theo bản cũ trong nhiều giờ.

CORS không phải phân quyền: một origin trong allowlist vẫn phải qua đủ bốn câu hỏi ở topic api-security. Allow-Origin chỉ nói ai đọc được response, không nói ai được phép làm gì.

Lớp 3

Phát hiện: `Origin` lạ là một tín hiệu độ nhiễu rất thấp

Client hợp lệ của bạn gửi một tập Origin hữu hạn và gần như không đổi. Nên một Origin ngoài allowlist là tín hiệu sạch hơn hầu hết mọi thứ trong SIEM.

  • Log mọi request có Origin không nằm trong allowlist, kèm path và user id. Alert khi tốc độ tăng — đó là chữ ký của một lần dò CORS đang chạy.
  • Đặc biệt chú ý Origin: null: nó tới từ iframe sandbox và data: URL, nên nó gần như luôn là một lần thử.
  • Và một alert cho chính cấu hình: nếu bất kỳ response nào ra ngoài mang Allow-Origin khác allowlist, đó là bug ở code, không phải tấn công. Một synthetic check hằng giờ với Origin: https://canary.invalid bắt được nó.
07

Kiểm chứng đã vá

Topic này có một đặc điểm hiếm: phép kiểm quan trọng nhất là một dòng curl, và nó chạy được trên production ngay bây giờ.

1. Dò bằng một Origin bịa hoàn toàn — nếu nó được phản chiếu thì bạn đã có lỗ hổng:

Shell
B=https://api.app.exampleH=$(curl -si "$B/api/me" -H 'Origin: https://canary.invalid' | tr -d '\r')echo "$H" | grep -qi 'access-control-allow-origin: *https://canary.invalid' \  && { echo "PHẢN CHIẾU Origin — lỗ hổng"; exit 1; }echo "$H" | grep -qi 'access-control-allow-origin: *\*' \  && echo "$H" | grep -qi 'allow-credentials: *true' \  && { echo "* kèm credentials — trình duyệt sẽ từ chối, nhưng cấu hình này là sai"; exit 1; }exit 0

2. Test lấy BẢNG PHÉP SO SAI ở khối 3 làm dữ liệu vào. Đây là bảng sống lâu, và test là nơi nó nên sống — khi ai đó tìm ra cách viết thứ tám, họ thêm một dòng [InlineData]. Xem tab csharp / test.

3. Kiểm Vary: Origin có mặt — biện pháp mà cả policy đúng lẫn code đúng vẫn thiếu:

Shell
H=$(curl -si "$B/api/me" -H 'Origin: https://app.example.com' | tr -d '\r')echo "$H" | grep -qi '^access-control-allow-origin' && {  echo "$H" | grep -qi '^vary:.*origin' \    || { echo "response phụ thuộc Origin mà thiếu Vary: Origin — CDN sẽ trộn cache"; exit 1; }}exit 0

4. Kiểm PREFLIGHT riêng. Một OPTIONS cấu hình sai trong khi GET đúng vẫn là lỗ, vì preflight là chỗ trình duyệt hỏi trước:

Shell
curl -si -X OPTIONS "$B/api/me" \  -H 'Origin: https://canary.invalid' \  -H 'Access-Control-Request-Method: DELETE' \  -H 'Access-Control-Request-Headers: authorization,x-custom' \  | grep -i 'access-control'# KHÔNG được có Allow-Origin cho canary.invalid, và Allow-Methods không được là *

5. Kiểm localhost không có trong allowlist production — cấu hình Development rò sang là nguyên nhân thật của phần lớn trường hợp:

Shell
for o in http://localhost:3000 http://127.0.0.1:5173 http://localhost; do  curl -si "$B/api/me" -H "Origin: $o" | grep -qi '^access-control-allow-origin' \    && { echo "localhost trong allowlist production: $o"; exit 1; }doneexit 0

6. Nếu có CDN: kiểm Origin nằm trong cache key. Gọi hai lần với hai Origin khác nhau và khẳng định header trả về khác nhau. Nếu lần hai nhận header của lần một thì Vary không được CDN tôn trọng — và đó là lớp lỗi mà không test nào ở tầng ứng dụng thấy được.

C#Bảng phép so sai ở khối 3 là dữ liệu test — cộng Vary và preflight.
public class CorsTests : IClassFixture<ProductionLikeFixture>{    private readonly ProductionLikeFixture _fx;   // allowlist = https://app.example.com     public CorsTests(ProductionLikeFixture fx) => _fx = fx;     /// <summary>    /// Mỗi dòng là một hàng của bảng phép so sai ở khối 3. Đặt chúng thành DỮ LIỆU    /// test là toàn bộ khác biệt: khi ai đó tìm ra cách viết thứ tám, họ thêm một    /// dòng ở đây thay vì sửa một điều kiện trong code sản phẩm.    /// </summary>    [Theory]    [InlineData("https://evil.example")]                    // origin hoàn toàn khác    [InlineData("https://evil-example.com")]                // EndsWith thiếu ranh giới    [InlineData("https://notapp.example.com")]              // cùng lỗi    [InlineData("https://app.example.com.evil.com")]        // StartsWith không neo cuối    [InlineData("https://evil.com/?x=app.example.com")]     // Contains không neo hai đầu    [InlineData("https://a.b.app.example.com")]             // regex subdomain quá rộng    [InlineData("http://app.example.com")]                  // đúng host, SAI scheme    [InlineData("https://APP.EXAMPLE.COM")]                 // khác chữ hoa thường    [InlineData("null")]                                     // iframe sandbox / data:    [InlineData("http://localhost:3000")]                    // cấu hình dev rò sang    public async Task Disallowed_origin_gets_no_allow_header(string origin)    {        var req = new HttpRequestMessage(HttpMethod.Get, "/api/me");        req.Headers.Add("Origin", origin);         var res = await _fx.Client.SendAsync(req);         // Khẳng định là header KHÔNG CÓ, không phải request bị 403: CORS không chặn        // request, nó chỉ không cấp phép đọc response. Một test kiểm status code sẽ        // xanh trên cả cấu hình sai.        Assert.False(res.Headers.Contains("Access-Control-Allow-Origin"),            $"origin {origin} được cấp phép đọc response");    }     [Fact]    public async Task Allowed_origin_gets_the_header_and_vary()    {        var req = new HttpRequestMessage(HttpMethod.Get, "/api/me");        req.Headers.Add("Origin", "https://app.example.com");         var res = await _fx.Client.SendAsync(req);         Assert.Equal("https://app.example.com",            res.Headers.GetValues("Access-Control-Allow-Origin").Single());         // Vary: Origin. Đây là khẳng định bắt được lớp lỗi mà cả allowlist đúng lẫn        // phép so đúng vẫn có: thiếu nó thì một CDN ở trước cache response này rồi        // trả nó cho một origin khác. Tự viết middleware CORS là lúc dòng này biến mất.        Assert.Contains("Origin", res.Headers.Vary, StringComparer.OrdinalIgnoreCase);    }     /// <summary>    /// Preflight kiểm RIÊNG. Một OPTIONS cấu hình sai trong khi GET đúng vẫn là lỗ,    /// vì preflight là chỗ trình duyệt hỏi TRƯỚC — nếu nó nói được thì trình duyệt    /// không hỏi lại nữa.    /// </summary>    [Fact]    public async Task Preflight_from_a_disallowed_origin_is_not_approved()    {        var req = new HttpRequestMessage(HttpMethod.Options, "/api/me");        req.Headers.Add("Origin", "https://canary.invalid");        req.Headers.Add("Access-Control-Request-Method", "DELETE");        req.Headers.Add("Access-Control-Request-Headers", "authorization");         var res = await _fx.Client.SendAsync(req);         Assert.False(res.Headers.Contains("Access-Control-Allow-Origin"));    }     /// <summary>Origin đúng vẫn không được cấp mọi method và mọi header.</summary>    [Fact]    public async Task Preflight_narrows_methods_and_headers()    {        var req = new HttpRequestMessage(HttpMethod.Options, "/api/me");        req.Headers.Add("Origin", "https://app.example.com");        req.Headers.Add("Access-Control-Request-Method", "GET");         var res = await _fx.Client.SendAsync(req);         var methods = res.Headers.GetValues("Access-Control-Allow-Methods").Single();        Assert.DoesNotContain("*", methods);        Assert.DoesNotContain("TRACE", methods, StringComparison.OrdinalIgnoreCase);         var headers = res.Headers.GetValues("Access-Control-Allow-Headers").Single();        Assert.DoesNotContain("*", headers);    }     /// <summary>    /// Cấu hình fail NHANH khi sai hình dạng. Một allowlist chứa localhost ở    /// production, hay một origin thiếu scheme, phải làm app không khởi động được —    /// vì cả hai đều là lỗ hổng và cả hai đều không có triệu chứng nào khác.    /// </summary>    [Theory]    [InlineData("app.example.com")]              // thiếu scheme    [InlineData("http://app.example.com")]       // không https ở production    [InlineData("https://app.example.com/api")]  // có path    [InlineData("http://localhost:3000")]        // localhost ở production    public void Invalid_production_origin_fails_startup(string origin)    {        var config = new ConfigurationBuilder()            .AddInMemoryCollection([new("Cors:AllowedOrigins:0", origin)])            .Build();         Assert.Throws<InvalidOperationException>(            () => new ServiceCollection().AddSecLabCors(config, ProductionEnvironment));    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Phản chiếu Origin kèm Allow-Credentials: trueCho MỌI origin đọc dữ liệu đã xác thực. Đây là dạng phổ biến nhất, và nó là lỗ hổng đầy đủ
o.EndsWith(".example.com")evil-example.com khớp. Bảng ở khối 3 có bảy hàng và không hàng nào là ngoại lệ hiếm
Regex cho subdomainCho phép mọi subdomain, kể cả subdomain mà người dùng tạo được hoặc một subdomain bị chiếm
null trong allowlistOrigin: null tới từ iframe sandbox và data: URL — tức là từ trang kẻ tấn công
Kiểm GET mà không kiểm OPTIONSPreflight là chỗ trình duyệt hỏi TRƯỚC. Một OPTIONS sai là đủ
Thiếu Vary: OriginCDN cache response kèm Allow-Origin của origin này rồi trả cho origin khác. Policy đúng, code đúng, và vẫn rò
localhost trong allowlist productionKẻ tấn công không dùng trực tiếp được, nhưng một app desktop hay malware local thì được
Dùng CORS làm phân quyềnRequest đã tới server kèm cookie TRƯỚC khi CORS có nghĩa. curl không quan tâm CORS
Allow-Origin: * bị coi là tệ nhấtTrình duyệt từ chối * kèm credentials, nên nó không rò dữ liệu cần đăng nhập. Phản chiếu mới là tệ nhất
Bật CORS rộng "để local dev chạy"Đúng nguyên nhân thật của phần lớn trường hợp. Cấu hình theo môi trường, và kiểm điều đó bằng test

Sai lầm về bản chất, và nó là gốc của mọi hàng trên: nghĩ CORS là một cơ chế bảo vệ. Nó là một cơ chế nới lỏng. Mọi dòng cấu hình CORS đều đang bỏ đi một phần của same-origin policy, nên câu hỏi khi review không phải "nó chặn gì" mà "nó cho ai đọc dữ liệu của tôi".

Sai lầm về phạm vi: kiểm API chính rồi dừng. Lỗ thật gần như luôn ở một subdomain ít ai để ý — một API nội bộ, một service cũ, một endpoint metrics. Đó cũng là hình dạng của phần lớn báo cáo bug bounty ở khối 5.

09

Nguồn tham khảo

Bậc 1Fetch Standard — CORS protocol · WHATWG · Living Standard · 2025
Bậc 1Enable Cross-Origin Requests (CORS) in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Bậc 1Cross-Origin Resource Sharing (CORS) · MDN · HTTP guide · 2025
Bậc 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Bậc 2Cross-origin resource sharing (CORS) · PortSwigger · Web Security Academy
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…