Skip to content

cross-origin-cors-reference/same-origin-simulator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

same-origin-simulator

An interactive playground for the same-origin policy. It starts real HTTP servers on several loopback origins, fires real cross-origin requests between them from your own browser, and shows you two things side by side that you can normally never see together:

  • what actually crossed the wire, reported by the receiving server — including the OPTIONS preflight, which the Fetch API hides from JavaScript entirely; and
  • what fetch() was allowed to give your code — which is often far less, and in a failure is nothing at all.

That contrast is the entire point. Almost every hour lost to CORS is lost because those two views were assumed to be the same view.

same-origin-simulator 1.0.0

  front-end   http://localhost:8080
  same server http://127.0.0.1:8080   <- a different origin, on purpose
  API         http://localhost:8081

  bound: app, api, app-v6, api-v6
  open http://localhost:8080 and pick a scenario
  press Ctrl-C to stop

Contents

Why this exists

CORS is taught badly, and mostly for one reason: the evidence is split across two places and nobody looks at both.

The browser tells you Access to fetch has been blocked by CORS policy. Your server logs say 200 OK. Both are correct. The request was sent, the handler ran, side effects happened, a response came back — and then the browser refused to hand it to your script. The failure is deliberately opaque: fetch() rejects with a bare TypeError carrying no status, no headers and no body, because leaking the status of a cross-origin response would itself be an information leak.

And when a preflight is involved it gets worse, because the request that failed is one your code never wrote. You cannot log it, intercept it, or see it in a promise. It exists only in the Network panel and in the server's own logs.

This tool puts both halves on one screen. The API server records every request it receives — preflights included — and the page shows that log next to the exact Response object your code was handed. Reading a scenario takes about a minute; the mechanics tend to stop being mysterious after two or three.

It is a teaching instrument rather than a diagnostic one. Everything runs on loopback, nothing is sent anywhere, and the server is deliberately misconfigurable in ways you would never ship.

Requirements

  • Python 3.9 or newer.
  • A browser.

Nothing else. No third-party packages, no build step, no network access. The Python side is standard library only and the front-end is vanilla HTML, CSS and JavaScript served as plain files.

Running it

Clone the repository and run the launcher directly:

git clone <your-clone-url> same-origin-simulator
cd same-origin-simulator
python3 simulate

Your browser opens at http://localhost:8080. Pick a scenario from the left, press Run this scenario, and read the five panels.

If you would rather type simulate than python3 simulate, mark it executable and put it on your PATH:

chmod +x simulate
ln -s "$PWD/simulate" ~/.local/bin/simulate

To stop it, press Ctrl-C.

Open the browser devtools Network panel while you use this. The simulator shows you the server's view; the Network panel shows you the browser's. Seeing the same preflight in both is what makes it stick.

Usage and flags

usage: simulate [-h] [--port N] [--api-port N] [--host HOST] [--no-browser]
                [--log-level {quiet,info,debug}] [--version]
Flag Default What it does
--port N 8080 Port for the front-end origin. The 127.0.0.1 variant of this same port becomes the second origin.
--api-port N 8081 Port for the configurable API origin. Must differ from --port; the two origins are only distinct because their ports are.
--host HOST 127.0.0.1 Loopback interface to bind. Only localhost, 127.0.0.1 and ::1 are accepted — see the security note.
--no-browser off Do not open a browser window on start. Useful over SSH or in a container.
--log-level info quiet prints nothing but errors. info prints the startup banner. debug additionally logs every HTTP request both servers handle to stderr.
--version Print the version and exit.
-h, --help Full help, including the origin map and exit codes.

If a port is taken you get a specific message and a suggested alternative rather than a traceback:

$ python3 simulate --port 8080
simulate: error: port 8080 is already in use ([Errno 98] Address already in use).
Try a different front-end port, for example:
    python3 simulate --port 8090 --api-port 8091

How CORS actually works

The model in four parts. Every scenario in the tool is an instance of one of them.

1. An origin is (scheme, host, port), compared as written.

Not as resolved. http://localhost:8080 and http://127.0.0.1:8080 are different origins even though they are the same socket on the same machine, because origin comparison happens on the host string and never consults DNS. http and https on the same host differ. Port 8080 and port 8081 differ. There is no partial matching, no subdomain rule, no "same machine" concept.

2. The policy governs reading, not sending.

This is the part that surprises people. The browser sends your cross-origin request. The server receives it and runs its handler. The response comes back. Then the browser checks whether the response granted your origin permission to read it, and if not, throws the whole thing away before your code sees it. A blocked POST still created the record.

3. Some requests get a preflight first.

A request is simple — sent directly, no preflight — only when all of these hold:

  • the method is GET, HEAD or POST;
  • every author-set header is CORS-safelisted (Accept, Accept-Language, Content-Language, Content-Type, Range, plus the client hints);
  • if Content-Type is set, its value is one of application/x-www-form-urlencoded, multipart/form-data or text/plain;
  • the body is not a ReadableStream, and there is no listener on an XMLHttpRequest upload object.

Break any of those and the browser first sends an OPTIONS request carrying Access-Control-Request-Method and, if relevant, Access-Control-Request-Headers. The server must answer with a 2xx status and matching Access-Control-Allow-Methods / Access-Control-Allow-Headers. A 3xx or 4xx fails the preflight no matter how good the headers are, and redirects are never followed on a preflight.

The boundary is not arbitrary. An HTML form can already cross-origin POST those three content types without any JavaScript, so preflighting them would protect nothing that is not already exposed. application/json cannot be produced by a plain form, so it gets the extra round trip.

Preflights are cached per origin, per URL, per method and per header set, for Access-Control-Max-Age seconds. Browsers clamp that value rather than reject it: Chromium at 7200 seconds, Firefox at 86400, Safari at 600.

Preflight requests never carry credentials, even when the request they authorise will. If your authentication middleware runs before your CORS handling, it will see an anonymous OPTIONS and reject it — which is the single most common preflight bug in production.

4. Credentials raise the bar, and the response is filtered.

When a request carries cookies or TLS client certificates:

  • Access-Control-Allow-Origin: * is rejected. The server must name one concrete origin. Wildcard plus ambient cookies would mean "any site the user visits may read this user's authenticated data".
  • Access-Control-Allow-Headers: * and Access-Control-Allow-Methods: * are read literally, as a header or method actually named *.
  • Access-Control-Allow-Credentials: true is required in addition.

And whether or not credentials are involved, your script can only read the CORS-safelisted response headers — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma — unless the server names others in Access-Control-Expose-Headers. Everything else is stripped from the Headers object before your code touches it, silently, returning null rather than raising.

One more rule that bites in production: whenever Access-Control-Allow-Origin is computed from the request's Origin, the response must also carry Vary: Origin, or a shared cache will eventually hand one origin's ACAO value to a different origin.

The scenarios

Fifteen one-click lessons. Each reconfigures the API origin, fires a real request, and explains what happened and why.

The basics

Scenario What it teaches
Simple GET, allowed The minimum successful cross-origin read: one request, no preflight, ACAO echoing the origin, plus the Vary: Origin that has to accompany it.
Simple GET, blocked The same request without ACAO. The server answers 200 with a full body and JavaScript still gets nothing but a TypeError. The clearest possible demonstration that the policy blocks reading, not sending.

What triggers a preflight

Scenario What it teaches
Custom request header X-Request-Id is not safelisted, so a plain GET stops being simple. Two requests on the wire where JavaScript wrote one.
Content-Type: application/json The header is safelisted; that value is not. Why "POST is a simple method" is a trap.
DELETE Methods outside GET/HEAD/POST preflight unconditionally, with no headers involved.

Credentials

Scenario What it teaches
Credentialed request meets a wildcard ACAO: * and ACAC: true are each valid and are rejected together, specifically because credentials are in play. Explains the CSRF-with-readback primitive the rule exists to prevent.
Credentialed request done correctly The three parts that are all required: one concrete allowlisted origin, ACAC: true, and Vary: Origin.

When the preflight fails

Scenario What it teaches
Preflight returns 403 Perfect CORS headers, non-2xx status, hard failure. The signature bug of auth middleware ordered in front of CORS handling — works in curl, works in Postman, fails only in a browser.
Preflight is redirected (301) Redirects are never followed on a preflight. Caused in the wild by HTTPS upgrades, trailing-slash normalisation and load-balancer canonicalisation — all invisible to server-side tests.

Preflight caching

Scenario What it teaches
Max-Age caching Fires the identical request twice and you watch the second one skip the preflight: three wire entries for two fetch() calls. Directly observable rather than merely asserted, and the highlight of the set. Covers the per-browser clamps.

Reading the response

Scenario What it teaches
Reading a header without Expose-Headers X-Total-Count: 137 is plainly there in the raw headers panel and response.headers.get() returns null. No error, no warning. Why pagination counts and rate-limit headers vanish.

Edge cases

Scenario What it teaches
null origin from a sandboxed iframe A genuinely sandboxed frame in an opaque origin sends Origin: null, and the API is set to trust it. Demonstrates that an allowlist containing null grants access to everyone while looking narrow in review.
Opaque response from mode: 'no-cors' fetch() resolves, with type: 'opaque', status: 0 and nothing readable. Why "just add no-cors" is the worst advice in this area. Also shows that a no-cors GET sends no Origin header at all.
localhost vs 127.0.0.1 The same server, the same port, the same bytes — and a blocked request, because the host strings differ.
Duplicate Access-Control-Allow-Origin The app sets it, the proxy sets it too, both correct, and the pair joins into origin, origin which matches nothing. The raw headers panel shows both field lines.

Worked example

Select Max-Age caching and press Run. The five panels fill in.

Panel 1 — the JavaScript that runs. Real, copyable, and the actual code path taken:

// Executed in a document served from http://localhost:8080
try {
  for (let i = 0; i < 2; i++) {
    const response = await fetch("http://localhost:8081/api/widgets", {
      method: "GET",
      mode: "cors",
      credentials: "omit",
      headers: {
        "X-Request-Id": "cached"
      }
    });
    console.log(response.status, await response.text());
  }
} catch (error) {
  // A CORS failure lands here as a bare TypeError:
  // no status, no headers, no body.
  console.error(error.name, error.message);
}

Panel 2 — what crossed the wire. Reported by the API server, not by the page:

#  Kind       Method   Path           Origin sent             Preflight asks for            Status
1  PREFLIGHT  OPTIONS  /api/widgets   http://localhost:8080   method GET; headers x-request-id  204
2  ACTUAL     GET      /api/widgets   http://localhost:8080   not applicable                    200
3  ACTUAL     GET      /api/widgets   http://localhost:8080   not applicable                    200

3 request(s) reached the API origin: 1 preflight, 2 actual. JavaScript called fetch() 2 time(s).
Only the first fetch paid for a preflight — the rest were served from the browser's
preflight cache, which is what Access-Control-Max-Age buys you.

Panel 3 — raw response headers, exactly as emitted:

Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin
Cache-Control: no-store

Panel 4 — what JavaScript got. response.type cors, status 200, ok true, and the list of headers the browser was willing to expose.

Panel 5 — the verdict, plus the console text your browser should be showing, plus links to the relevant background reading.

Now run Simple GET, blocked and compare panel 2 with panel 4: a healthy 200 OK on the wire, and a bare TypeError: Failed to fetch in your code.

Architecture: why three origins

                                   +---------------------------------------+
  browser                          |  simulate (one Python process)        |
  +-------------------------+      |                                       |
  | http://localhost:8080   |<---->|  app server   127.0.0.1:8080 + [::1]  |
  |   index.html, app.js    |      |    www/ + /__origins.json             |
  |                         |      |          + /__scenarios.json          |
  |  +-------------------+  |      |                                       |
  |  | iframe            |  |      |                                       |
  |  | http://127.0.0.1  |--+----->|  (same server, different origin)      |
  |  +-------------------+  |      |                                       |
  |  +-------------------+  |      |                                       |
  |  | sandboxed iframe  |  |      |                                       |
  |  | opaque origin     |--+----->|  api server   127.0.0.1:8081 + [::1]  |
  |  +-------------------+  |      |    /api/*        CORS per config      |
  |                         |      |    /__control/*  config + wire log    |
  +-------------------------+      +---------------------------------------+

Why a separate API port. Two origins only differ if their scheme, host or port differ. Serving the API from a second port is the simplest way to create a genuinely cross-origin relationship on one machine, with no hosts-file editing, no TLS and no DNS.

Why the 127.0.0.1 origin. The same server, reached by its other name, is a second origin for free. That makes the localhost-versus-127.0.0.1 lesson demonstrable rather than merely assertable — you can see one succeed and the other fail against the same allowlist. Requests from it are fired inside a hidden iframe pointed at that origin, so the page never has to reload.

Why the sandboxed iframe. sandbox="allow-scripts" without allow-same-origin puts a document in an opaque origin, and requests from an opaque origin carry Origin: null. That is a real browser behaviour, not a simulation: it is exactly how an attacker produces a null origin, in three lines of HTML.

Why both IPv4 and IPv6 loopback. On many systems localhost resolves to ::1 before 127.0.0.1. Binding only 127.0.0.1 would make the front-end reachable under one name and not the other, which would break the very lesson the tool is trying to teach. Each server therefore binds both, giving four listening sockets and one thread apiece. If the ::1 bind fails the launcher says so and carries on with IPv4.

Why the control channel is always CORS-open. /__control/* echoes any origin unconditionally and ignores the scenario config. If it obeyed the config, selecting a "blocked" scenario would lock you out of the controls that let you select the next one. It is also excluded from the wire log, so the log shows only the lesson.

Free play and the control API

Below the scenario list is a form that drives the API origin by hand: ACAO mode (absent, wildcard, echo, static allowlist, reflect-anything, trust-null), ACAC, Allow-Methods, Allow-Headers, Expose-Headers, Max-Age, preflight status code, artificial delay, duplicate-ACAO, and Vary: Origin — plus the request itself: origin to fire from, method, path, an extra header, fetch mode, credentials mode, and a repeat count.

The same control channel is a plain HTTP API, so you can drive it from curl while the browser watches:

Endpoint Method Purpose
/__control/config GET Read the live config.
/__control/config POST Replace it. Unknown keys and out-of-range values are rejected with 400 and a message. This is a full replace, not a merge.
/__control/log?since=N GET Every /api/ request received after sequence N, with the exact response header lines emitted.
/__control/reset POST Clear the log.
$ curl -s -X POST http://localhost:8081/__control/config \
    -H 'Content-Type: application/json' \
    -d '{"acao_mode":"echo","allow_methods":"PUT, OPTIONS","max_age":600}' > /dev/null

$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:8081/api/widgets \
    -H 'Origin: http://localhost:8080' \
    -H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: PUT, OPTIONS
Access-Control-Max-Age: 600
Vary: Origin
Cache-Control: no-store

Reproducing a preflight in curl like this is the standard way to isolate whether a CORS problem is in the server or in the browser. If curl shows the right headers and the browser still blocks, the difference is something the browser adds — credentials, a redirect, a duplicated header from a proxy.

Config keys: acao_mode, allowlist, acac, allow_methods, allow_headers, expose_headers, max_age, preflight_status, redirect_location, delay_ms, duplicate_acao, vary_origin.

The API surface itself is trivial on purpose: any path under /api/ answers 200 with a small JSON body describing what it saw, plus a deliberately non-safelisted X-Total-Count: 137 header for the Expose-Headers lesson.

Exit codes

Code Meaning
0 Clean shutdown (Ctrl-C).
1 Unexpected error, such as a missing www/ directory.
2 Bad usage: an unknown flag, an out-of-range port, identical --port and --api-port, or a non-loopback --host.
3 A required port was already in use. The message names the port and suggests a free alternative.

Security note

The servers bind to loopback interfaces only, and the launcher refuses to do otherwise. Passing anything other than localhost, 127.0.0.1 or ::1 to --host exits with code 2 rather than binding.

That restriction is not incidental. This tool's job is to serve deliberately broken CORS configurations: reflect-any-origin, trust-null, wildcard-with-credentials. Those are the exact misconfigurations a CORS security audit exists to find. Exposed on a routable interface they would be a genuine vulnerability rather than a lesson, so the tool does not offer you the option.

Related points:

  • It is not a proxy. It never forwards anything to a remote host, makes no outbound connections, and has no allowlist of upstreams because it has no upstreams. It cannot be used to bypass a real server's CORS policy, and if you are here looking for that, the answer is that the fix belongs on the server.
  • Nothing leaves your machine. No telemetry, no CDN, no fonts, no analytics. The front-end is served from disk and the only hosts involved are loopback.
  • Do not point it at anything you do not own. There is no facility to do so, but for the avoidance of doubt: probe only hosts you are authorised to test.
  • The scenario configurations are examples of what not to ship. reflect, null_trusted and wildcard-with-credentials are included so you can see them fail safely, not as templates.

Limitations, and what this deliberately is not

  • HTTP only, on loopback. No TLS. That means the SameSite=None; Secure cookie requirement cannot be demonstrated, because a Secure cookie will not be sent over plain HTTP — the credentialed scenarios say so where it matters.
  • It cannot read your console. A page has no access to its own devtools console; that is a browser privacy boundary and it is correct. The verdict panel quotes the message Chrome and Firefox are expected to print and asks you to compare, rather than pretending to have captured it.
  • Preflight caching is the browser's, not the tool's. The Max-Age scenario relies on your browser's preflight cache. A hard reload with "Disable cache" ticked in devtools, or a private window, will suppress it and the lesson will show two preflights instead of one. The panel tells you when that has happened.
  • Browsers differ at the edges. Where an observed outcome disagrees with the scenario's stated expectation, the UI says so plainly rather than hiding it. The wire trace is always the source of truth for what the server sent; the tests assert it on every run.
  • It does not diagnose your application. It teaches the model. There is no facility to point it at a real API, replay a captured request, or scan a host. Use curl for reproduction and the Network panel for diagnosis.
  • Not a CORS library or reference implementation. The server's header logic is written for legibility and for the ability to be wrong on purpose. Do not lift it into production.
  • One config at a time. The API origin holds a single global configuration in memory. Two browser tabs running scenarios simultaneously will fight over it.
  • No persistence. Config and log are in memory and vanish on exit. The log is capped at 400 entries.

Tests

The test suite drives the real API server over a real socket on an ephemeral port and asserts, for every scenario, that the response headers are exactly what the scenario's teaching text promises — status codes, preflight handling, the wildcard/credentials interaction, Vary, Max-Age and Expose-Headers placement, and the duplicate-header case. The scenarios are defined once, in simulator/scenarios.py, and consumed by both the UI and the tests, so a lesson cannot drift from what the server actually does.

$ python3 -m unittest discover -s tests -v
...
Ran 40 tests in 3.056s

OK

Standard library only. There is no CI configuration because there is nothing to install.

Further reading

Background on the mechanics demonstrated here, from cross-origin.com, a technical reference on CORS, preflight mechanics and browser security boundaries:

Contributing

See CONTRIBUTING.md. New scenarios are the most useful contribution, and each one needs a test that pins the headers it promises.

Licence

MIT. See LICENSE.

About

Interactive playground that fires real cross-origin requests between local sandboxed origins so you can watch the same-origin policy, credentials rules and preflight caching play out live. 15 guided scenarios.

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors