Choose your stack

Stacks without a card

How it happens

  • With fetch(url, { credentials: "include" }) the browser attaches the user's cookies for the API to a cross-origin request. The response decides whether the calling page may read it: Access-Control-Allow-Origin must equal the page's origin exactly and Access-Control-Allow-Credentials must be true. An API that echoes any Origin with credentials lets every site the user visits read the user's data through the user's own browser.
  • Browsers refuse Access-Control-Allow-Origin: * on credentialed requests, and three of the four frameworks refuse to configure it: Spring throws IllegalArgumentException on the first CORS request, ASP.NET Core throws InvalidOperationException at startup, and flask-cors raises ValueError when send_wildcard is set, while the cors package sends the header and leaves the browser to refuse it. The usual workaround is reflection: cors({ origin: true }), allowedOriginPatterns("*"), SetIsOriginAllowed(_ => true), or flask-cors's default origins="*" with supports_credentials=True, which echoes the Origin without complaint.
  • Pattern checks meant to cover all of a company's sites accept look-alikes. A substring test or an unanchored regular expression accepts https://portal.example.test.evil.example, where the trusted name is only the start of the attacker's domain; an ends-with test accepts https://evil-example.test, a domain anyone could register; an unescaped dot in portal.example.test also matches portal-example.test. The cors package's README illustrates its RegExp option with /example\.com$/, which also matches evil-example.com.
  • Allowlisting null, often added for local file: development, admits sandboxed iframes and data: URLs, which any page can create. Checks that ignore the scheme admit http:// origins, where anyone on the network path can inject script. Trusting every subdomain turns one XSS or dangling DNS record on any of them into access to the API.
  • CORS governs reading, not sending. A cross-origin GET, or a POST with a form or text/plain body, reaches the server without a preflight, so state-changing endpoints still need CSRF defences; a JSON PUT like this card's needs a preflight first, which an exact allowlist refuses and a reflecting one approves.
  • Whether the cookie is attached at all depends on the browser and the cookie: a SameSite=Lax cookie stays home on cross-site fetches but is sent from sibling subdomains, and a SameSite=None cookie, which an API needs to serve a front end on another site, is sent from any site in Chrome, which does not block third-party cookies by default, while Firefox and Safari restrict them by default. The same bug can be exploitable in one browser and not another, which is why the allowlist must not depend on it.

Fix principles

  • Use the framework's CORS support with an explicit list of full origins compared as exact strings: cors with an origin function in Express, flask-cors with literal origins, CorsConfiguration.setAllowedOrigins in Spring, WithOrigins in ASP.NET Core. Keep the list in configuration, per environment, with no wildcard fallback.
  • Never reflect the Origin header without that check, and keep pattern hooks (a RegExp or regex string, allowedOriginPatterns, SetIsOriginAllowed, a contains or ends-with test) away from credentialed endpoints. If every subdomain really is trusted, use the framework's subdomain matcher, allowedOriginPatterns("https://*.example.test") or SetIsOriginAllowedToAllowWildcardSubdomains(), which pins the scheme and requires the dot.
  • Send Access-Control-Allow-Credentials only with an allowed origin, and Vary: Origin on every response whose CORS headers depend on the Origin, refusals and requests without Origin included: the Fetch standard's note on CORS and HTTP caches asks for it, because a cache that stores a response without it can replay a refusal to a listed front end, or one origin's grant to another. The frameworks add Vary when they echo an allowed origin (ASP.NET Core and flask-cors only when more than one origin is listed), and only Spring adds it to refusals, so the Express, Flask and ASP.NET Core cards add it themselves. Mark per-user responses Cache-Control: no-store or private so that no shared cache serves them across origins or users.
  • Never allowlist null, http:// origins or origins you do not control, and keep development origins such as http://localhost:5173 out of production configuration.
  • Remember what CORS does not do: it does not authenticate or authorize requests and does not stop them being sent. Keep authorization on the server, keep CSRF protection on cookie-authenticated writes, and require a JSON content type or a custom header on endpoints that rely on the preflight.

The scenario

The API at https://api.example.test returns the signed-in user's invoices (GET /api/invoices) and changes their email (PUT /api/account/email with a JSON body). Two browser front ends call it with fetch(..., { credentials: "include" }): https://portal.example.test and https://admin.example.test, the only entries on the allowlist, which every stack reads from configuration. Requests carry the session cookie sid; the fixture has one user, Alice. Every stack runs the same thirteen tests through a small browser model that applies the Fetch standard's CORS check and preflight rules, so a test fails exactly when a browser would let the calling page read the response or send the update.

How the cards prove it

  • Run each card's tests in three modes: the fixed implementation must pass, and the vulnerable and the plausible-but-wrong implementations must fail. run-all.mjs does this for every card.
  • The exploit tests send what a page on another origin makes the browser send, with Alice's cookie: an arbitrary origin, Origin: null, https://evil-example.test, https://portal.example.test.evil.example, https://portal-example.test, http://portal.example.test and the sibling https://forum.example.test, plus a credentialed JSON PUT that needs a preflight. The browser model applies the Fetch standard's CORS check (exact Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true) and preflight rules, and each test fails if the page could read the invoices or the PUT would be sent. Functional tests keep both listed origins, the preflighted PUT and requests without Origin working, and require Vary: Origin on every response: those that echo an origin, a refused origin's and one to a request without Origin.
  • Static analysis barely covers framework CORS configuration. No Semgrep rule found anything on this card in any stack: Semgrep's flask-cors rule matches only an explicit origins="*", and its Express and Java rules only headers set by hand. CodeQL's js/cors-permissive-configuration models the cors package and should catch origin: true but not a RegExp, Sonar's S5122 caught AllowAnyOrigin() but not SetIsOriginAllowed, and nothing checked here models flask-cors or Spring's CorsConfiguration. Each card's scannerRules records what was run and what it found.

Identifiers

CWE-942 (permissive cross-domain policy with untrusted domains) describes the vulnerable and wrong implementations, whose CORS policy trusts origins it should not; OWASP Top 10:2025 maps it to A02 Security Misconfiguration. CWE-346 (origin validation error) describes the flawed origin check itself, and the 2025 list maps it to A07 Authentication Failures; Sonar's S5122 cites both. CAPEC is left empty on purpose: MITRE lists no attack pattern for CWE-942, and the patterns it lists for CWE-346, such as JSON hijacking and cache poisoning, describe different attacks. ASVS 5.0.0 requirement 3.4.2 asks for an allowlist when Access-Control-Allow-Origin is derived from the Origin header, and 3.5.2 applies to the card's PUT endpoint, which relies on the preflight.

References

  1. WHATWG Fetch Standard: CORS protocol, CORS check, credentials and HTTP caches
  2. MDN: Cross-Origin Resource Sharing (CORS)
  3. OWASP HTML5 Security Cheat Sheet: Cross Origin Resource Sharing
  4. OWASP Web Security Testing Guide: Testing Cross Origin Resource Sharing
  5. OWASP Top 10:2025 A02 Security Misconfiguration
  6. OWASP Top 10:2025 A07 Authentication Failures
  7. CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains
  8. CWE-346: Origin Validation Error
  9. OWASP ASVS 5.0.0, V3 Web Frontend Security (3.4.2, 3.5.2)
  10. PortSwigger Web Security Academy: CORS
  11. James Kettle: Exploiting CORS misconfigurations for Bitcoins and bounties
  12. MDN: Third-party cookies (browser defaults)

Practice in the browser

Guides on this weakness