Lukas' Notes

web security

Definition

Cross-Origin Resource Sharing

CORS is a mechanism that lets a server selectively relax the Same-Origin Policy for cross-origin reads. The server signals which origins may read its responses through the Access-Control-Allow-Origin response header. The browser lets JavaScript read the response body only if the request’s Origin header matches that allow-list (or if the value is the wildcard *).

// running on example.com — only succeeds if api.com allows it
const res = await fetch('https://api.com');
const html = await res.text();

The SOP still allows the fetch to be made; CORS only controls whether the response may be read.

Who sets the header

Who sets the header

Access-Control-Allow-Origin is returned by the server that receives the cross-origin request — the owner of the resource being read — not by the page’s own server and not by any central authority. In a frontend/API split (app.example.com calling api.example.com) it is the API that sets it; for cross-origin static assets it is the webserver serving those assets. Whether the header ends up coming from the app, a framework’s CORS middleware, or a reverse proxy is an implementation detail — the deciding question is always whose response is being read.

Why Blocking The Response Protects Anything

Intuition

The puzzle is that the server runs and even returns data — so what is CORS buying? The answer is that the attacker’s script is not the server, it is the browser: the script runs inside the victim’s browser, in the context of the page the victim is visiting. CORS is enforced by that browser, not by the server, and the browser is the only party that can hand the response body to the script in the first place.

Concretely, https://example.com’s script cannot reach across the network and pull bytes off a https://api.com socket itself — only the browser can perform the fetch on the script’s behalf. The browser is the man-in-the-middle and the authorised reader all at once. If the browser refuses to hand the response body to the script, the script simply has no other path to it. The server did send the bytes, but they arrived inside the browser, and the browser dropped them before the script could see them.

This is the right threat model to keep in mind. The attacker is a page running on the victim’s browser — it is sandboxed, and the browser is its only window to the network. The server is not defending itself against the page; it is consenting, through the browser, to let its data reach the page. The trust arrow runs server → browser → page, and the browser is the gate that breaks the chain when the server did not consent.

That is also why the request still being sent is fine: the server is the one that can be reached by the request, the server chose to handle it, and the server chose what data to send back. CORS never claimed to protect the server from being reached — it protects the confidentiality of the response from reaching a page the server did not authorise. Blocking the request itself is a separate problem, and a harder one, which is why CSRF needs its own story.

Both rows ship bytes from api.com over the wire into the browser — the top row’s response and the bottom row’s response are indistinguishable at the network layer. The difference is made entirely inside the browser, at the gate that decides whether the page may read the body. The script has no other route to the data, so closing that last hop closes the whole attack.

Obs

CORS itself is an HTTP-layer mechanism — the Origin/Access-Control-Allow-Origin comparison works over plain http:// too, and applies identically to http://localhost:3000 fetching http://localhost:4000 (different ports ⇒ cross-origin ⇒ needs the header to read the body). What TLS adds is not the read gate; it anchors the origin of the response, so a network attacker cannot forge the Access-Control-Allow-Origin header that would grant their own page access. In other words, TLS protects the integrity of the server’s consent; it is not what makes CORS function. That is why HTTPS is the right context for cross-origin APIs in production, and also why http://localhost — which browsers treat as a secure context by exception — works perfectly well for local development despite not being HTTPS.

Example

Reading a cross-origin API from a browser page

A page at https://example.com wants to read https://api.com/data from client-side JavaScript. The fetch is sent regardless, but the SOP blocks example.com from seeing the body unless api.com opts in:

GET /data HTTP/2
Host: api.com
Origin: https://example.com
HTTP/2 200
Access-Control-Allow-Origin: https://example.com
Content-Type: application/json
 
{ "hello": "world" }

Because Access-Control-Allow-Origin matches the request’s Origin, the browser hands res.text() to the script.

The same fetch blocked by a missing or mismatched Origin

Now api.com either omits the header or names a different origin:

HTTP/2 200
Content-Type: application/json
 
{ "secret": "data" }

The request still succeeds at the HTTP level — api.com even returns 200 and the body crosses the network — but the browser refuses to surface it:

const res = await fetch('https://api.com/data');
await res.text();   // throws: blocked by CORS policy

example.com’s script never sees { "secret": "data" }. The contrast with the positive case is the whole point of CORS: the request goes through either way, so CORS is purely a read gate on the response — it protects the confidentiality of api.com’s data against pages api.com did not authorise. It is not, and was never meant to be, a defence against the request being made, which is why CSRF remains a separate problem.