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.
Why you should care
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
curlfreely; 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 withcredentials, so it cannot leak data that requires a login. The most dangerous configuration is reflectingOriginplusAllow-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.
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.
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 attackerThe 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:
| Code | Origin that passes | Why |
|---|---|---|
o.EndsWith(".example.com") | https://evil-example.com | Missing the dot at the boundary |
o.EndsWith("example.com") | https://notexample.com | Same bug, more obvious |
o.StartsWith("https://app.example.com") | https://app.example.com.evil.com | Not anchored at the end |
o.Contains("example.com") | https://evil.com/?x=example.com | Not anchored at either end |
Regex("^https://.*\.example\.com$") | https://a.b.example.com | Allows every subdomain — including user-creatable ones |
Reflecting Origin unvalidated | every origin | The most common form in the wild |
An allowlist containing null | null (from a sandboxed iframe, data:) | Origin: null comes from an attacker-controlled iframe |
Three details fixes usually miss:
Vary: Originis mandatory whenever you reflect or select by origin. Without it, a CDN or reverse proxy caches the response with one user'sAllow-Originand serves it to the next — turning a correct configuration into a hole.- Preflight must be validated too. An
OPTIONSreturningAllow-Origin: *while theGETvalidates correctly is still a hole, because preflight is where the browser asks first. http://localhostin 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.
Concrete example
The same API, three configurations. Only the third is safe.
# ① 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'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.
# ② 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)"donehttps://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: nullThree of four pass. EndsWith(".example.com") misses the boundary, and null is in the allowlist.
# ③ 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// ── ① 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();});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,}));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.
How to defend
An allowlist compared by exact string — no regex, no EndsWith
mandatoryThe 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.
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.comis not an origin;https://example.comis. Without the scheme,http://app.example.compasses and the data travels unencrypted. - No
nullin the allowlist.Origin: nullarrives from a sandboxed iframe or adata:URL — that is, from a page the attacker fully controls. - No
localhostin 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.
/// <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();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();});`Vary: Origin` whenever the response depends on `Origin`
mandatoryThis 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.
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 ofapi.example.com. With no cross-origin request there is no CORS policy to misconfigure. This is also how SecLab does it:lib/api.tscalls 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. ThenAllow-Credentialsneed 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.
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 permitsDELETEon 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.
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
Originis 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 anddata:URLs, so it is almost always an attempt. - And one alert on the configuration itself: if any outbound response carries an
Allow-Originoutside the allowlist, that is a code bug rather than an attack. An hourly synthetic check withOrigin: https://canary.invalidcatches it.
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:
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 02. 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:
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 04. Check PREFLIGHT separately. A misconfigured OPTIONS alongside a correct GET is still a hole, because preflight is where the browser asks first:
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:
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 06. 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.
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)); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Reflect Origin with Allow-Credentials: true | Lets 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 subdomains | Allows every subdomain, including user-creatable ones and compromised ones |
null in the allowlist | Origin: null comes from sandboxed iframes and data: URLs — that is, from the attacker's page |
Validate GET but not OPTIONS | Preflight is where the browser asks FIRST. One wrong OPTIONS is enough |
Missing Vary: Origin | A 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 allowlist | An attacker cannot use it directly, but a desktop app or local malware can |
| Using CORS as authorisation | The request already reached the server with cookies BEFORE CORS meant anything. curl does not care about CORS |
Treating Allow-Origin: * as the worst case | Browsers 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.
Comments
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.
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…