SecLab

CORS misconfiguration

V3A02
01

What it is

CORS is the mechanism that lets a page read a response from another origin — it relaxes the same-origin policy, it does not tighten it. A CORS misconfiguration means you have allowed an attacker's page to read your users' data, using their own session.

02

Why you should care

Relevance: CoreExpected: L2

The essential point: CORS does not protect your server — it protects your users, and every CORS configuration is a decision to remove part of that protection. So the right question when reading a CORS policy is not "what does it block" but "who am I letting read my data".

Two common misconceptions, both leading to the same vulnerability:

  • "CORS stops attackers from calling my API"no. An attacker calls your API with curl freely; CORS exists only in the browser. It decides whether a web page can read the response.
  • "`Access-Control-Allow-Origin: is the most dangerous"* — it is not. ` cannot* be combined with credentials, so it cannot leak data that requires a login. The most dangerous configuration is reflecting Origin plus Allow-Credentials: true: that lets every origin read authenticated data.

And where the real bugs live: almost-correct Origin comparisons. Nobody writes allowedOrigins = ["*"] with credentials. They write origin.EndsWith(".example.com") — and evil-example.com matches, or attacker.com#.example.com matches, or they use StartsWith and https://app.example.com.evil.com matches. Four lines of code, four ways to be wrong, and all of them are one regex or one Contains.

03

How the attack works

The mechanism: the browser still sends the request and still attaches cookies; it only decides whether JavaScript may read the response, based on the headers the server returns.

Diagram source
sequenceDiagram    autonumber    actor V as Victim (signed into app)    participant E as evil.example    participant A as api.app.example    V->>E: Opens the attacker page    E-->>V: fetch(api, #quot;credentials: include#quot;)    V->>A: GET /api/me<br/>Origin: https://evil.example<br/>Cookie: session=...    Note over A: The server REFLECTS Origin<br/>and sets Allow-Credentials.    A-->>V: 200<br/>Access-Control-Allow-Origin: https://evil.example<br/>Access-Control-Allow-Credentials: true    Note over V: The browser sees matching headers<br/>→ LETS JavaScript read the response.    V->>E: Ships the read data back to the attacker

The key point at step ③: the request already reached the server, with cookies, before CORS meant anything. So CORS is never an authorisation control — it only decides who can read the result.

The wrong-Origin-comparison table — the most important table on this page, and each row is a real CVE:

CodeOrigin that passesWhy
o.EndsWith(".example.com")https://evil-example.comMissing the dot at the boundary
o.EndsWith("example.com")https://notexample.comSame bug, more obvious
o.StartsWith("https://app.example.com")https://app.example.com.evil.comNot anchored at the end
o.Contains("example.com")https://evil.com/?x=example.comNot anchored at either end
Regex("^https://.*\.example\.com$")https://a.b.example.comAllows every subdomain — including user-creatable ones
Reflecting Origin unvalidatedevery originThe most common form in the wild
An allowlist containing nullnull (from a sandboxed iframe, data:)Origin: null comes from an attacker-controlled iframe

Three details fixes usually miss:

  • Vary: Origin is mandatory whenever you reflect or select by origin. Without it, a CDN or reverse proxy caches the response with one user's Allow-Origin and serves it to the next — turning a correct configuration into a hole.
  • Preflight must be validated too. An OPTIONS returning Allow-Origin: * while the GET validates correctly is still a hole, because preflight is where the browser asks first.
  • http://localhost in the production allowlist. An attacker cannot use it directly, but a desktop app or locally-running malware can.

Diagram description: Sequence diagram: a victim signed into the app opens a page on evil.example. That page calls fetch against the API with credentials include, so the browser sends the request with the session cookie and an Origin header of evil.example. The server reflects that Origin into Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials. The browser sees the two headers match, so it lets JavaScript read the response, and the attacker page ships the data it read back to their own server.

04

Concrete example

The same API, three configurations. Only the third is safe.

Shell
# ① Probe with a completely invented Origin. If it comes back reflected, you are done.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      ← reflectedAccess-Control-Allow-Credentials: true                  ← and cookies allowed(no Vary: Origin)

Those two lines together mean every website on the internet can read your users' data.

Shell
# ② The "we validate the domain" fix — and the block 3 table shows it is not enough.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 "(blocked)"done
https://evil-example.com        → Access-Control-Allow-Origin: https://evil-example.comhttps://app.example.com.evil.com → (blocked)https://a.b.example.com        → Access-Control-Allow-Origin: https://a.b.example.comnull                           → Access-Control-Allow-Origin: null

Three of four pass. EndsWith(".example.com") misses the boundary, and null is in the allowlist.

Shell
# ③ After the fix: exact comparison, and Vary: Origin present.curl -si https://api.app.example/api/me -H 'Origin: https://evil.example' | grep -iE 'access-control|vary'# (no Access-Control-Allow-Origin at all — the origin is not allowlisted)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#Three misconfigurations, each arising from a legitimate development need.
// ── ① 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();});
TypeScriptThe same bug in Express: `origin: true` means "reflect whatever arrives".
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

What happened in the wild

A bug bounty family rather than one large incident. CORS misconfiguration is among the most-reported categories on HackerOne and Bugcrowd, with the same shape recurring: reflected Origin plus Allow-Credentials: true on an internal API or an overlooked subdomain. The typical impact is reading a victim's full profile, tokens, and sometimes API keys, for any user who visits an attacker-controlled page.

CVE-2021-3172 (Ansible Tower) and the family of framework-level flaws like it. Several frameworks and CORS libraries have shipped defaults or options meaning "any origin with credentials" — and people enabled them because it was the fastest way to get a SPA running locally. Memorable because it shows the real cause: misconfiguration starts from a development need, not from carelessness.

And one more shape worth knowing: a missing Vary: Origin behind a CDN. This is a class of bug where both the CORS policy and the code are correct — the CDN caches a response carrying one origin's Allow-Origin and serves it to another. It only appears in production, after a CDN exists, and no application-layer test can see it.

06

How to defend

Layer 1

An allowlist compared by exact string — no regex, no EndsWith

mandatory

The block 3 table is the whole argument: every almost-correct comparison has an origin that walks through. The only way to have no "almost" is exact comparison against a finite set.

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

StringComparer.Ordinal, not OrdinalIgnoreCase: origins are case-sensitive per spec, and a case-insensitive compare opens surface you do not need.

Three things that must come with it:

  • Both the scheme and the port in the string. example.com is not an origin; https://example.com is. Without the scheme, http://app.example.com passes and the data travels unencrypted.
  • No null in the allowlist. Origin: null arrives from a sandboxed iframe or a data: URL — that is, from a page the attacker fully controls.
  • No localhost in production configuration. It belongs in the Development config file, and that difference must be enforceable by test (see block 7).

In ASP.NET Core: WithOrigins(...) performs the exact comparison. Do not use SetIsOriginAllowed(_ => true) — that is reflection, and it is the most common form of this bug.

C# · Layer 1Exact match against a finite set, per-environment config, automatic Vary, narrow preflight.
/// <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 1A finite Set, exact match, and `Vary: Origin` set by hand since `cors` does not always add it.
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();});
Layer 1b

`Vary: Origin` whenever the response depends on `Origin`

mandatory

This is the control that a correct policy and correct code can still be missing, and it turns a safe configuration into a hole once a CDN sits in front.

The mechanism: if a response carries Access-Control-Allow-Origin: https://app.example.com with no Vary: Origin, then every intermediate cache (a CDN, a reverse proxy, even a shared browser cache) treats that as the response for that URL. The next person — from a different origin — receives it with the previous origin's permission header. The reverse is worse: a response for an allowlisted origin gets cached and served to a request from the attacker's origin.

Vary: Origin tells caches "this response differs by the Origin header", so they key it per origin.

ASP.NET Core adds it automatically when you use WithOrigins. But hand-written CORS middleware does not — and hand-writing it is exactly what happens when you need custom logic. The block 7 check targets this.

And if a CDN is involved: verify at the CDN layer that Origin is part of the cache key, rather than trusting Vary.

Layer 1c

Do not need CORS: same origin, or a token instead of cookies

The strongest control removes the problem, and it is more often feasible than people assume.

  • Put the API and the web app on the same origin. app.example.com/api/* behind a reverse proxy instead of api.example.com. With no cross-origin request there is no CORS policy to misconfigure. This is also how SecLab does it: lib/api.ts calls from a server component, so the browser never sees a cross-origin request.
  • If a different origin is unavoidable: use Authorization: Bearer, not cookies. A token has to be placed into the header by JavaScript, so a cross-origin page cannot attach it automatically — unlike a cookie. Then Allow-Credentials need never be enabled, and this whole family disappears.

State the trade-off plainly: a token in localStorage is readable via XSS, an HttpOnly cookie is not (see xss layer 3). So this choice trades one risk family for another, it is not a pure improvement. Same origin plus HttpOnly cookies is the best option where it is achievable.

Layer 2

Narrow what you allow, even for a correct origin

A correct origin does not mean allowing everything. Four things to narrow:

  • Access-Control-Allow-Methods: only the methods the API actually uses. * is surplus and it permits DELETE on a read-only endpoint.
  • Access-Control-Allow-Headers: list them explicitly. * permits every header, including ones some proxy configuration trusts.
  • Access-Control-Expose-Headers: by default the browser lets JS read only seven safe headers. Every header you expose is a decision — do not expose ones carrying internal information.
  • Access-Control-Max-Age: keep it moderate (600s). Too long and a corrected policy is still served from the browser's old preflight cache for hours.

And CORS is not authorisation: an allowlisted origin still has to pass all four questions from the api-security topic. Allow-Origin says who can read the response, not who may do what.

Layer 3

Detection: an unexpected `Origin` is a very low-noise signal

Your legitimate clients send a finite and near-constant set of Origin values. So an Origin outside the allowlist is a cleaner signal than almost anything else in a SIEM.

  • Log every request whose Origin is not allowlisted, with the path and user id. Alert on the rate — that is the signature of a CORS probe in progress.
  • Pay particular attention to Origin: null: it comes from sandboxed iframes and data: URLs, so it is almost always an attempt.
  • And one alert on the configuration itself: if any outbound response carries an Allow-Origin outside the allowlist, that is a code bug rather than an attack. An hourly synthetic check with Origin: https://canary.invalid catches it.
07

Verifying the fix

This topic has a rare property: the most important check is one line of curl, and it runs against production right now.

1. Probe with a completely invented Origin — if it comes back reflected, you have the vulnerability:

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 "REFLECTED Origin — vulnerable"; exit 1; }echo "$H" | grep -qi 'access-control-allow-origin: *\*' \  && echo "$H" | grep -qi 'allow-credentials: *true' \  && { echo "* with credentials — browsers reject it, but this config is wrong"; exit 1; }exit 0

2. A test taking the WRONG-COMPARISON TABLE from block 3 as input data. That table is the long-lived artefact, and the test is where it should live — when somebody finds an eighth spelling they add one [InlineData]. See the csharp / test tab.

3. Check Vary: Origin is present — the control that a correct policy and correct code can still be missing:

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 "the response depends on Origin but has no Vary: Origin — a CDN will mix caches"; exit 1; }}exit 0

4. Check PREFLIGHT separately. A misconfigured OPTIONS alongside a correct GET is still a hole, because preflight is where the browser asks first:

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'# There must be no Allow-Origin for canary.invalid, and Allow-Methods must not be *

5. Check localhost is not in the production allowlist — Development config leaking through is the actual cause in most cases:

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 is in the production allowlist: $o"; exit 1; }doneexit 0

6. With a CDN: verify Origin is part of the cache key. Call twice with two different Origin values and assert the returned headers differ. If the second call receives the first call's header, the CDN is not honouring Vary — and that is a class of bug no application-layer test can see.

C#Block 3's wrong-comparison table as test data — plus Vary and 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

Common mistakes

The "fix"Why it is wrong
Reflect Origin with Allow-Credentials: trueLets EVERY origin read authenticated data. The most common form, and a complete vulnerability
o.EndsWith(".example.com")evil-example.com matches. The block 3 table has seven rows and none of them is a rare edge case
A regex for subdomainsAllows every subdomain, including user-creatable ones and compromised ones
null in the allowlistOrigin: null comes from sandboxed iframes and data: URLs — that is, from the attacker's page
Validate GET but not OPTIONSPreflight is where the browser asks FIRST. One wrong OPTIONS is enough
Missing Vary: OriginA CDN caches a response with one origin's Allow-Origin and serves it to another. Correct policy, correct code, still leaking
localhost in the production allowlistAn attacker cannot use it directly, but a desktop app or local malware can
Using CORS as authorisationThe request already reached the server with cookies BEFORE CORS meant anything. curl does not care about CORS
Treating Allow-Origin: * as the worst caseBrowsers reject * with credentials, so it cannot leak login-gated data. Reflection is the worst case
Opening CORS wide "so local dev works"The actual cause in most cases. Configure per environment, and enforce that with a test

The conceptual mistake, and the root of every row above: thinking CORS is a protection mechanism. It is a relaxation mechanism. Every line of CORS configuration removes part of the same-origin policy, so the review question is not "what does it block" but "who am I letting read my data".

The scoping mistake: checking the main API and stopping. The real hole is almost always on an overlooked subdomain — an internal API, an old service, a metrics endpoint. That is also the shape of most bug bounty reports in block 5.

09

References

Tier 1Fetch Standard — CORS protocol · WHATWG · Living Standard · 2025
Tier 1Enable Cross-Origin Requests (CORS) in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Tier 1Cross-Origin Resource Sharing (CORS) · MDN · HTTP guide · 2025
Tier 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Tier 2Cross-origin resource sharing (CORS) · PortSwigger · Web Security Academy
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…