Part 9 · 8 chapters · ~65 min

The Security Model

The browser runs code from strangers next to data from your bank, and the whole platform is the set of rules that keeps those apart. This part is origin versus site and what each guards, CORS as read permission, CSP as the XSS backstop and how to roll it out, the three attacks the headers exist for, site isolation and the COOP/COEP/CORP triad, sandboxed frames and the permission model, and the HTTPS and header set that every production response should carry.

94

Origin and site: the two boundaries

the question

"Same-origin, same-site, cross-origin, cross-site. Which one does the browser actually check, and for what?"

There are two boundaries, and most security confusion in web apps comes from using one where the browser uses the other. Origin is (scheme, host, port), matched exactly. Site is scheme plus the registrable domain, the part of the host directly under a public suffix. Script access, storage, and response reads are guarded by origin. Cookies, SameSite, and process placement are guarded by site.

origin guards
  1. DOM access across frames. iframe.contentWindow.document throws unless same-origin.
  2. Reading responses. A cross-origin fetch's body is unreadable without CORS. Images and scripts can be loaded (the old web depended on it) but not inspected: canvas becomes tainted, script errors become "Script error." without crossorigin.
  3. Storage. localStorage, IndexedDB, Cache API, OPFS: per origin, further partitioned by top-level site.
  4. Permissions. Camera, geolocation, notifications: granted per origin.
site guards
  1. Cookies. Domain=example.com spans subdomains. SameSite compares the request's site to the top-level site.
  2. Process isolation. Chrome's unit is the site: cross-site frames go to other processes; same-site frames share one.
  3. Storage partitioning key. The top-level site, combined with the origin.
the consequence that bites
A subdomain you do not fully control (a marketing site on a vendor's platform, an old CNAME to a decommissioned service, a user-content subdomain) is same-site with your app. It can set cookies your app's domain will accept (cookie tossing), it is a valid SameSite source for requests, and it can be a CORS origin if your allowlist uses a regex on the suffix. The Public Suffix List exists so that github.io users are not same-site with each other; your own subdomains have no such protection.
ORIGIN VERSUS SITE
the two boundaries, and what each one guards
swipe the figure sideways, or tap expand for full screen
1/6
origin
Origin is the tuple (scheme, host, port). Two URLs are same-origin only if all three match exactly. https://app.example.com and https://api.example.com are different origins. https://example.com and https://example.com:8443 are different origins.
95

CORS: who may read what

Cross-Origin Resource Sharing is the protocol by which a server tells the browser that a different origin may read its responses. The browser enforces it; the server only declares. Two facts resolve most CORS confusion: it protects reads, not writes; and the server already processed a simple request before the browser decided whether to show you the response.

the mechanics
  1. Simple requests (GET, HEAD, POST; Content-Type of form-urlencoded, multipart, or text/plain; no custom headers) go straight out with an Origin header. The response is readable if Access-Control-Allow-Origin matches.
  2. Everything else is preflighted. An OPTIONS request with Access-Control-Request-Method and -Headers asks permission. The server answers with Allow-Origin, Allow-Methods, Allow-Headers, and Max-Age (cache the answer; Chrome caps at two hours). If the preflight fails, the real request is never sent.
  3. Credentials (cookies, Authorization, client certs) are excluded unless credentials: 'include'. Then the server must send Allow-Credentials: true and an exact origin, never *, and should send Vary: Origin so a shared cache does not serve one origin's permission to another.
  4. Response headers are filtered. Script sees only the CORS-safelisted ones unless Access-Control-Expose-Headers lists more.
  5. Opaque responses. mode: 'no-cors' sends the request and yields a response with status 0 and nothing readable. Useful only for caching or firing a beacon.
the mistakes
  1. Reflecting the Origin header unconditionally. Equivalent to * with credentials, which the browser would otherwise forbid. Any site can read authenticated responses.
  2. Allowlisting by suffix regex. /example\.com$/ matches notexample.com. Compare exact origins from a list.
  3. Assuming CORS stops the request. A cross-site POST with a form content type is simple: it is sent, cookies and all (subject to SameSite), and the server runs it. That is CSRF, and CORS is not the defence; SameSite cookies and tokens are.
  4. Preflight on every request. A custom header (X-Requested-With, a tracing header) on every GET doubles the round trips. Set Max-Age and consider whether the header needs to be custom.
CORS, STEP BY STEP
simple request, preflight, credentials
swipe the figure sideways, or tap expand for full screen
1/6
simple
A simple request: GET, HEAD or POST with a simple content type and no custom headers. The browser sends it immediately with an Origin header. The server responds; the browser checks Access-Control-Allow-Origin before letting script read the body. The request happened either way.
96

Content Security Policy: the XSS backstop

CSP is a response header that tells the browser which sources of script, style, images, frames and connections the page is allowed to use. Its main job is to make cross-site scripting non-exploitable even when an injection exists: the attacker gets HTML into the page, but the browser refuses to run it.

a modern policy
  1. script-src 'nonce-<random>' 'strict-dynamic'. Only scripts carrying this response's nonce run; scripts they create inherit permission. Host allowlists are obsolete: they are bypassable through any JSONP endpoint or open redirect on an allowed host, and they break on every CDN change.
  2. object-src 'none'. No plugins.
  3. base-uri 'none' or 'self'. An injected <base> would redirect every relative script URL.
  4. frame-ancestors 'none' or a list. The replacement for X-Frame-Options; clickjacking defence.
  5. form-action 'self'. An injected form cannot post credentials elsewhere.
  6. require-trusted-types-for 'script' where you can: innerHTML and friends only accept Trusted Types objects, which forces every sink through a sanitiser. The strongest DOM XSS defence available.
  7. report-to with a Reporting-Endpoints header. Violations arrive as JSON. Noisy (extensions trigger them) but invaluable during rollout.
what breaks, and what to do
  1. Inline event handlers (onclick="...") and javascript: URLs never run under a nonce policy. Convert to addEventListener. This is the bulk of a migration on an older codebase.
  2. Inline styles if you also lock style-src. Most teams leave style-src looser; style injection is a lesser attack.
  3. eval, new Function, string setTimeout. Some libraries need them (old template engines). Replace or isolate; 'unsafe-eval' reopens a wide hole.
  4. Third-party tags. A tag manager that injects scripts needs 'strict-dynamic' and must itself be nonced. Its own injected scripts then run; so does anything it was configured to inject, which is a governance problem not a CSP one.
  5. SSR nonces. The nonce must be fresh per response and reach every script tag, including those from the framework. Every major framework has a nonce prop; caches must not cache the HTML with a nonce in it (or must cache per-nonce, which defeats caching). Static hosting without a server cannot do nonces; hashes ('sha256-...' of each inline script) are the alternative for static sites.
worked numbers
rollout:

  week 1–2   Content-Security-Policy-Report-Only: <policy>; report-to csp
              collect; group by blocked-uri and line; fix
  week 3      enforce for 5% of responses
  week 4      enforce for all

Report-Only is free and precise. there is no reason to go from nothing to enforce.
CONTENT SECURITY POLICY
what a strict policy blocks, and how to deploy one
swipe the figure sideways, or tap expand for full screen
1/7
the policy
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'. The nonce is generated per response and placed on every script tag the server intends.
97

XSS, CSRF, clickjacking: the attacks the headers are for

cross-site scripting
  1. Stored: attacker content saved server-side and rendered to other users. Reflected: attacker content in a URL rendered back in the response. DOM-based: the page's own script reads an attacker-controlled source (location.hash, postMessage data) and writes it to a sink (innerHTML, eval, location).
  2. First defence: output encoding by context. HTML body, attribute, JS string, URL, CSS: each needs its own escaping. Frameworks that escape by default (React's JSX, Vue templates) cover the HTML context; dangerouslySetInnerHTML, href="javascript:", and template literals into innerHTML are the holes.
  3. Second defence: CSP as above. Injection without execution.
  4. Third: Trusted Types, which makes the sinks refuse strings.
  5. Sanitising HTML when you must render user HTML: DOMPurify or the Sanitizer API; never a regex; never on the server alone (the client's parser is the one that matters).
cross-site request forgery
  1. The attack: a page on evil.site submits a form (or triggers a simple request) to bank.example; the browser attaches bank.example's cookies; the server sees an authenticated request.
  2. SameSite=Lax stops cross-site POSTs carrying the cookie. Default in Chrome; not in all browsers for all cases; subdomains you do not control are same-site.
  3. Synchroniser token: a per-session random value in a hidden field or header, checked server-side. The classic defence; still correct.
  4. Origin / Sec-Fetch-Site checking: the server rejects state-changing requests whose Sec-Fetch-Site is cross-site. Cheap and reliable in modern browsers.
  5. Custom header requirement: an API that only accepts JSON with a custom header is not simple, so cross-site requests preflight and fail. Only for APIs, and only if CORS is strict.
clickjacking, and the fetch metadata family
  1. Clickjacking: your page in an invisible iframe over a decoy; the user clicks your button thinking it is theirs. frame-ancestors in CSP (or X-Frame-Options: DENY) prevents the framing.
  2. Sec-Fetch-Site / -Mode / -Dest / -User headers: the browser tells the server how a request was initiated. A resource isolation policy on the server ("images may be fetched cross-site; everything else must be same-origin or a navigation") blocks whole classes of attack in a few lines of middleware.
  3. Referrer-Policy: strict-origin-when-cross-origin (the default now): full URL to same-origin, origin only cross-origin, nothing on downgrade. Path-bearing referrers leaked tokens in URLs for years.
98

Site isolation and the isolation headers

Spectre changed what a renderer process is allowed to contain. If any code in a process can read any memory in it via timing, then cross-origin data must not share a process with untrusted code. Chrome's answer is site isolation: one process per site, enforced for all sites on desktop and for sites the user logs into on Android. The isolation headers extend that to subresources, which site isolation alone cannot cover.

what site isolation does
  1. Cross-site frames go out of process. The parent holds a RemoteFrame proxy; compositing stitches the surfaces; input is routed by the browser process to the right renderer.
  2. Cross-Origin Read Blocking / Opaque Response Blocking. A cross-origin response that is HTML, XML or JSON requested as an image or script never reaches the renderer; the network service sniffs and drops it. Sensitive JSON cannot be pulled into an attacker's process via an img tag.
  3. What it cannot cover: resources you legitimately load cross-origin (fonts, scripts, images). They are in your process. That is why SharedArrayBuffer and precise timers were removed after Spectre, and why they come back only with the isolation headers.
the headers
  1. Cross-Origin-Opener-Policy: same-origin. Severs window references with cross-origin openers and openees. The popup your OAuth flow opens becomes unreachable via window.opener; use postMessage or redirects. same-origin-allow-popups keeps the popup relationship while protecting you.
  2. Cross-Origin-Embedder-Policy: require-corp. Every cross-origin subresource must opt in via CORS or CORP, or fail. credentialless: non-opted-in resources load without credentials instead.
  3. Cross-Origin-Resource-Policy: same-origin | same-site | cross-origin. Set on the resource. Public CDN assets: cross-origin. Your API: same-site at most, and prefer same-origin.
  4. The result: crossOriginIsolated. SharedArrayBuffer, Wasm threads, high-resolution timers, performance.measureUserAgentSpecificMemory.
when to bother
Only if you need SharedArrayBuffer (Wasm threads, ffmpeg.wasm, a multithreaded audio or physics engine) or precise memory measurement. For everyone else, COOP: same-origin-allow-popups and CORP: same-origin on your own API responses are cheap and worth having; full COEP is a project.
ISOLATION HEADERS
COOP, COEP, CORP, and what they unlock
swipe the figure sideways, or tap expand for full screen
1/6
threat
The threat: Spectre. Any cross-origin data that ends up in your process can, in principle, be read through timing side channels. Site isolation keeps cross-site frames out of your process, but subresources (images, scripts, fonts you fetch) are still loaded into it.
99

Sandboxing: iframes, permissions, and the Permissions Policy

iframe sandbox
  1. sandbox with no value: the frame gets a unique opaque origin (no storage, no cookies, no same-origin access even to its own origin), no scripts, no forms, no popups, no top navigation, no plugins.
  2. Re-enable selectively: allow-scripts, allow-forms, allow-popups, allow-same-origin (restores the real origin: with allow-scripts this lets the frame remove its own sandbox if it is same-origin with you, so never combine those for untrusted content from your own origin), allow-top-navigation-by-user-activation, allow-downloads.
  3. srcdoc for inline content with a sandbox; csp attribute to impose a policy on the framed document (Chromium).
  4. The pattern for untrusted HTML: a sandboxed iframe on a separate origin (user-content.example.net), with allow-scripts only if needed, communicating over postMessage with origin checks. Rendering it in your own origin's DOM, however sanitised, is the risk you are avoiding.
Permissions Policy
  1. Header: Permissions-Policy: camera=(), geolocation=(self), fullscreen=(self "https://video.example"). Which powerful features this document and its frames may use.
  2. Iframe allow attribute: delegates specific features to a specific frame. Without it, a cross-origin frame cannot use the camera even if the user would grant it.
  3. Use it to: stop third-party frames using sensors, autoplay, or payment APIs; turn off features your app never uses so an injected frame cannot either; and, in newer browsers, control document-level behaviours like unload handlers and synchronous XHR.
the permission model
  1. Per origin, remembered. Grants persist until revoked; Chrome auto-revokes unused permissions after months.
  2. Requires a user gesture for the prompt on most features; a prompt with no gesture is blocked or quietly denied.
  3. Query first: navigator.permissions.query({ name }) returns granted, denied, or prompt. Never prompt on page load; ask in context, after the user has done the thing that needs it, and handle denied with a path to re-enable in browser settings.
100

HTTPS, mixed content, HSTS, and the certificate chain

what the padlock means
  1. Identity: the certificate chains to a root the OS or browser trusts, covers the hostname, is within its validity, and (Chrome, Safari) appears in Certificate Transparency logs.
  2. Confidentiality and integrity: TLS 1.2 or 1.3 with a modern cipher. The network course has the handshake.
  3. Not: that the site is honest. A phishing site has a valid certificate too.
mixed content
  1. Blockable: http scripts, iframes, fetches, fonts, WebSockets on an https page are blocked outright.
  2. Upgradable: http images, audio, video are rewritten to https and fail if that fails. Chrome upgrades them automatically.
  3. Content-Security-Policy: upgrade-insecure-requests rewrites everything on your page to https; the fix for a legacy codebase full of http:// asset URLs.
HSTS
  1. Strict-Transport-Security: max-age=31536000; includeSubDomains; preload. After the first https visit, the browser refuses to make http requests to the host for a year and refuses to click through certificate errors. The first visit is the gap; the preload list closes it by shipping your domain in the browser.
  2. includeSubDomains is a commitment. Every subdomain, including the forgotten dev one, must serve valid https or be unreachable.
the rest of the header set
  1. X-Content-Type-Options: nosniff. Stops the browser guessing a content type; a text file served as text/plain cannot be executed as script.
  2. Referrer-Policy, Permissions-Policy, CSP, frame-ancestors, COOP/CORP: covered above. A security headers scanner will list the same set; the point is knowing what each one is for.
101

Reading security in DevTools

WhereShowsUse it for
Security panelCertificate, chain, TLS version, cipher, mixed content, per-origin breakdown"Is this page actually secure"; which subresource broke the padlock
Console (CORS errors)The exact reason: missing Allow-Origin, credentials with *, preflight method not allowedReading the message instead of guessing; it names the header
Network → a request → HeadersOrigin, Sec-Fetch-*, Access-Control-*, Set-Cookie with blocked reasonsSeeing what the browser sent and what the server said; preflights appear as separate OPTIONS rows (enable the filter)
Console (CSP violations)Directive, blocked URI, source file and lineWhat a policy would block; what it did block
Issues panelGrouped security issues: cookies, CORS, CSP, mixed content, COEPA checklist with links to the offending request
Application → FramesEach frame's origin, sandbox flags, COOP/COEP state, isolation statusConfirming crossOriginIsolated; seeing why it is false
Application → Cookies (blocked column)Cookies the browser refused to set or send, with reasonsSameSite, Secure, prefix violations
chrome://process-internalsWhich sites are in which processVerifying site isolation; seeing OOPIFs
go to the lab
  1. Route /security/cors-playground: a page on one port, an API on another. Toggle the API's headers (Allow-Origin, Allow-Credentials, Expose-Headers) and watch each fetch succeed or fail with the console's exact reason.
  2. Route /security/csp-report-only: a page with inline handlers and an injected script under Report-Only. Read the violations; flip to enforce; see what dies.
  3. Route /security/csrf: a cross-site form POST to a cookie-authenticated endpoint. Observe SameSite=Lax blocking the cookie in the Network Cookies tab, then set SameSite=None and watch it go through.
  4. Route /security/isolation: a page that reads crossOriginIsolated, with COOP/COEP toggles, a cross-origin image with and without CORP. Watch the Application → Frames panel update.