Skip to content
academia.sh

Lesson 16 / 27

Cross-Origin Resource Sharing

What the same-origin policy blocks, the distinction between simple requests and preflight, what the sharing headers mean, the rules for credentialed requests, and common misconfigurations.

Contents

The previous lesson established that the content security policy decides which origin a script may load from. Once the script has loaded, a separate boundary takes over. The North Slope Measurement Station interface is served from https://measurements.example.test, while the measurement data comes from an endpoint at a separate domain. When the interface’s script sends a request to that endpoint, it is not the policy but the same-origin policy that decides whether the script may read the response.

This lesson builds that decision, and the audited way of relaxing it.

What the Same-Origin Policy Blocks

As defined in the Browser and the Web Platform course, an origin is the triple formed by scheme, host name, and port. If any component of the triple differs, the origin differs: https://measurements.example.test and https://data.example.test are separate origins, and so is http://measurements.example.test.

The most common misconception about what the same-origin policy does is the assumption that it blocks the request from being sent. What is blocked is reading the response. A script can send a request to another origin; the request reaches the server, the server processes it and returns a response; the browser just does not deliver that response to the script. If the request produced a side effect, that side effect has already happened.

This distinction is what drives the next lesson’s topic, and it should be noted here: the policy restricts reading, not writing.

The policy applies only to connections opened from script. Image, stylesheet, font, and script elements can load from other origins; the document embeds them but cannot read their content. The opaque response concept, introduced in the Service Workers lesson of the Browser and the Web Platform course, is the name for this state: the response can be used, but its body and headers are not visible to the program.

Simple Requests and Preflight

Some cross-origin requests are sent directly; others ask for permission before being sent.

Requests sent directly are the ones forms have long been able to make across origins: the GET, HEAD, or POST method, a limited set of content types, and no headers the application has added on its own. These requests get no extra round trip; the browser sends the request and decides whether the response can be read by checking the headers that come back.

Every other request requires preflight. If the method is PUT or DELETE, if the content type is application/json, or if the application is adding a header that starts with X-, the browser first sends a separate request with the OPTIONS method. In this request, the Access-Control-Request-Method and Access-Control-Request-Headers headers tell the server what the real request is about to do. If the server allows it, the real request is sent; if not, the real request never happens.

The reason preflight exists is backward compatibility. The requests a browser can send directly are already the requests forms have always been able to send; new capabilities are not opened up without the server’s knowledge.

Server-Side Configuration

The server below serves measurement data and makes the sharing decision through a single allowlist.

// cors-server.mjs — cross-origin resource sharing configuration for the measurement endpoint
import { createServer } from 'node:http';

const ALLOWED_ORIGINS = new Set(['https://measurements.example.test']);
const ALLOWED_METHODS = 'GET, POST, OPTIONS';
const ALLOWED_HEADERS = 'content-type, x-measurement-key';

const server = createServer((request, response) => {
  const origin = request.headers.origin;
  const allowed = origin !== undefined && ALLOWED_ORIGINS.has(origin);

  response.setHeader('Vary', 'Origin');
  if (allowed) {
    response.setHeader('Access-Control-Allow-Origin', origin);
    response.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  if (request.method === 'OPTIONS') {
    if (allowed) {
      response.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS);
      response.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS);
      response.setHeader('Access-Control-Max-Age', '600');
    }
    response.writeHead(204).end();
    return;
  }

  response.setHeader('Content-Type', 'application/json');
  response.end(JSON.stringify({ station: 'north-slope', temperature: -3.4 }));
});

server.listen(8791, '127.0.0.1', () => {
  console.log('listening: http://127.0.0.1:8791');
});

The server is started in the background, three requests are sent, and it is stopped at the end. The -D - flag writes the response headers to standard output; tr -d '\r' strips the transport line-ending character, and grep filters out the date line, which changes on every run.

node cors-server.mjs &
server=$!
sleep 1

echo '--- 1. preflight from an allowed origin ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 2. preflight from an origin not on the list ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://other.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 3. real request from an allowed origin ---'
curl -s -D - http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' | tr -d '\r' | grep -v '^Date:'

kill $server
listening: http://127.0.0.1:8791
--- 1. preflight from an allowed origin ---
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, x-measurement-key
Access-Control-Max-Age: 600
Connection: keep-alive
Keep-Alive: timeout=5

--- 2. preflight from an origin not on the list ---
HTTP/1.1 204 No Content
Vary: Origin
Connection: keep-alive
Keep-Alive: timeout=5

--- 3. real request from an allowed origin ---
HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Content-Type: application/json
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 44

{"station":"north-slope","temperature":-3.4}

The second case shows what a refusal looks like. The server does not return an error status; it just does not write the permission headers. The decision belongs to the browser, and after a preflight response with no permission headers, the browser never sends the real request at all. This also explains why the refused case does not show up as an error in the server log.

What the Headers Mean

Access-Control-Allow-Origin declares the single origin allowed to read the response. Its value is either a full origin string or an asterisk denoting all origins.

Access-Control-Allow-Methods and Access-Control-Allow-Headers are meaningful only in a preflight response; they list which method and which headers the real request may use. Access-Control-Max-Age says how many seconds the preflight result may be cached for, which keeps another round trip from being made before every request.

Access-Control-Expose-Headers extends which headers on the response the script may read. By default only a small set of headers can be read; an endpoint carrying pagination information in a custom header has to list that header here, or the script never sees it.

Vary: Origin is not a permission header, but its absence is a real defect. Because the response’s content changes based on the request’s Origin header, every cache along the path has to include this header in the cache key. If it does not, permission headers produced for one allowed origin can be served to a different origin.

Credentialed Requests

Attaching cookies and identity headers to a cross-origin request also has to be requested explicitly. On the client side this is done by marking that the request should carry credentials; on the server side, two rules apply at once.

First, the Access-Control-Allow-Credentials header must carry the value true. Second, Access-Control-Allow-Origin cannot be an asterisk; a full origin string must be written. This rule keeps a “publicly open” configuration from unintentionally covering requests that carry credentials.

Whether the cookie is actually sent depends on one more condition: the cookie’s SameSite attribute has to allow cross-origin sending. This attribute is the subject of the next lesson, and the two mechanisms have to be considered together; even if the sharing headers are correct, if the cookie attribute blocks it, the request goes out without credentials.

Common Misconfigurations

Reflecting the incoming origin as-is. A server that writes the request’s Origin header back into the response without validating it looks like it is maintaining an allowlist but has actually allowed every origin. The example above does reflect, but only for a value already on the list.

Trying to combine an asterisk with credentials. The browser rejects this; switching to reflecting the origin to get around this rejection reproduces the first defect.

Writing the domain match loosely. A check that accepts any name ending in example.test also accepts example.test.other.test. The match has to be made against the full origin, not through the end of a string.

Allowing the null origin. Some contexts send the Origin header as null; putting this value on the allowlist means allowing all of those contexts.

Sharing Is Not Authorization

Cross-origin resource sharing protects the user’s browser from having another site’s script read data. It does not protect the server. A client that is not a browser does not observe these headers at all; writing an origin name that is not on the allowlist does not form a barrier either, because nothing outside a browser is required to honor the Origin header.

The rule that follows is clear: an endpoint is not considered protected without authentication and authorization. A sharing configuration does not stand in for these checks; it is layered on top of them.

Summary

  • The same-origin policy does not block a cross-origin request from being sent; it blocks reading its response. A request that produces a side effect still happens.
  • Requests that fail to meet the simple-request conditions go through a preflight round with the OPTIONS method; if the server does not allow it, the real request never happens.
  • A refusal shows up as the absence of permission headers, not as an error status.
  • For credentialed requests, Access-Control-Allow-Origin cannot be an asterisk and Access-Control-Allow-Credentials is required; the cookie’s SameSite attribute must also allow it.
  • Sharing configuration is for the browser; the server’s authentication and authorization are still required independently of it.

Next Step

An open question is left at the start of this lesson: if the same-origin policy blocks reading and a write request still reaches the server, can another site’s page delete a measurement record through the user’s session? The response not being readable does not mean the operation did not happen. The next lesson takes up this class of attack and the defense built from cookie attributes and request tokens.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close