Web Security · Draft

7 — Frames and headers

Chapter 6 closed the last way another site could read the app’s data through a request. CORS now lets exactly one other origin, the partner’s dashboard, read the API, and answers “no” to everyone else. That rule is about reading. Other sites can still do things with the app’s pages without reading them. They can show a page inside a frame and put something on top of it. They can load one of its URLs as a script or an image. They can open it in a new window and keep a handle to that window. The browser allows each of these unless the app says otherwise.

The app says otherwise through response headers. None of them changes what the server does; each one tells the browser to refuse something on the app’s behalf. This chapter goes through them in the order the app needs them, shows what Chrome does with and without each one, and ends with an audit of every header the app sends.

The lab

The app runs at https://notes.test:5301 with a self-signed certificate for notes.test, *.notes.test, attacker.test and partner.example. A second Flask process on port 5302 with the same certificate plays every other site. It answers as https://attacker.test:5302, mallory’s site; as https://pages.notes.test:5302, a sibling subdomain and therefore the same site as the app (chapter 1 covered the difference); and as https://partner.example:5302, the partner from chapter 6. Headless Chrome 157.0.8103.40 is driven over the DevTools protocol with two flags:

--host-resolver-rules=MAP notes.test 127.0.0.1, MAP *.notes.test 127.0.0.1, MAP attacker.test 127.0.0.1, MAP partner.example 127.0.0.1
--ignore-certificate-errors-spki-list=<SHA-256 of the certificate's public key>

The second flag makes Chrome treat this one certificate as valid instead of merely ignoring errors. That matters for HSTS, which a browser must ignore on a connection with certificate errors. A first attempt with --ignore-certificate-errors showed exactly that: the header arrived and Chrome discarded it.

The session cookie is __Host-sid with Secure and HttpOnly, and with SameSite=None, where the partner integration in chapter 6 left it. Every form carries a CSRF token from chapter 3, and unsafe requests go through its Fetch Metadata check. Alice is logged in. Mallory is the attacker.

A page inside someone else’s page

The note page has a share form: a text box for a username and a Share button. It’s protected against forged requests by a token in a hidden field, and the token works because no other site can read the page to learn it.

Framing gets around that without reading anything. The attacker builds a page with a harmless-looking form, loads the app’s note page in an <iframe> on top of it, and makes the iframe nearly transparent. The person sees the attacker’s page. Their clicks and keystrokes land on the app’s page, which is really there, with alice’s session and a valid token in its form. This is clickjacking, also called UI redressing.

Mallory’s page lines up her decoy controls with the app’s real ones:

<style>
  #code  { position: absolute; top: 120px; left: 40px; width: 200px; height: 30px }
  #claim { position: absolute; top: 160px; left: 40px; width: 200px; height: 40px }
  iframe { position: absolute; top: 0; left: 0; width: 600px; height: 400px; border: 0;
           opacity: 0.0001; z-index: 2 }
</style>
<p>You won! Type the code <b>mallory</b> in the box, then press Claim.</p>
<input id="code"><button id="claim">Claim</button>
<iframe src="https://notes.test:5301/notes/3"></iframe>

Note 3 is alice’s “Salary review”. With the iframe’s opacity raised to 0.5 for a screenshot, the app’s username box sits exactly over the “code” box and its Share button exactly over Claim. At 0.0001 the iframe is invisible but still receives input. The DevTools driver plays the victim: it clicks the code box, types mallory, and clicks Claim.

await a.click(140, 135);                               // the "code" box
await a.s("Input.insertText", { text: "mallory" });
await a.click(140, 180);                               // "Claim"

Cross-site: the note was shared

From https://attacker.test:5302, the run produced this network log. The iframe is a cross-site frame, so Chrome runs it in a separate process; the lines marked [frame] came from that process.

  -> GET https://attacker.test:5302/p/clickjack.html
     Sec-Fetch-Site: none, Sec-Fetch-Dest: document
  -> GET https://notes.test:5301/notes/3  Referer: https://attacker.test:5302/
     cookie: __Host-sid=…
     Sec-Fetch-Site: cross-site, Sec-Fetch-Dest: iframe
== user clicks the code box, types mallory, clicks Claim
  [frame] -> POST https://notes.test:5301/notes/3/share  Referer: https://notes.test:5301/notes/3
  [frame]    cookie: __Host-sid=…
  [frame]    Sec-Fetch-Site: same-origin, Sec-Fetch-Dest: iframe

and the app logged:

WARNING in app: SHARED note 3 of alice with mallory

Every check from chapter 3 passed, because every one of them was satisfied honestly. The form came from the app’s own page, so the CSRF token was right. The POST was sent by a document on notes.test, so Sec-Fetch-Site said same-origin. A Fetch Metadata check that rejects cross-site and same-site doesn’t see anything wrong. The only hint is Sec-Fetch-Dest: iframe, which says the form was submitted inside a frame. Chrome did flag the cookie, with a WarnThirdPartyPhaseout issue on each use, because a SameSite=None cookie in a cross-site frame is a third-party cookie.

With the cookie back at SameSite=Lax, which is where it belongs once the partner moves to a token of its own, the same page got nowhere. The iframe’s request went out with no cookie, the frame loaded the app logged out, and the clicks landed on an error page. That’s the cookie setting covering for a missing defense, and only against other sites.

Same site: the note was shared anyway

With SameSite=Lax, the attack page was served from https://pages.notes.test:5302 instead:

  -> GET https://notes.test:5301/notes/3  Referer: https://pages.notes.test:5302/
     cookie: __Host-sid=…
     Sec-Fetch-Site: same-site, Sec-Fetch-Dest: iframe
== user clicks the code box, types mallory, clicks Claim
  -> POST https://notes.test:5301/notes/3/share  Referer: https://notes.test:5301/notes/3
     cookie: __Host-sid=…
     Sec-Fetch-Site: same-origin, Sec-Fetch-Dest: iframe

and the app logged SHARED note 3 of alice with mallory again.

SameSite doesn’t help against a sibling subdomain, as chapter 2 measured. A user-content subdomain, a marketing site run by another team, or any subdomain taken over through a dangling DNS record (chapter 11) is enough. Cookie settings also vary across browsers and change over time. The real fix is for the app to say whether it may be framed at all.

frame-ancestors and X-Frame-Options

Two headers do this. The older one is X-Frame-Options, with two working values: DENY (never framed) and SAMEORIGIN (framed only when every ancestor frame has the app’s origin). A third value, ALLOW-FROM, is obsolete, and MDN notes that modern browsers ignore the whole header when they see it. The newer one is the CSP directive frame-ancestors, which takes a source list: 'none', 'self', or specific origins. The CSP Level 3 spec says 'none' is roughly DENY, 'self' is roughly SAMEORIGIN, and when both headers are present, frame-ancestors wins.

Each was tried on its own, then both together, against both attack pages; the results were the same from either site:

HeaderChrome’s resultShare request sent?
X-Frame-Options: SAMEORIGINnet::ERR_BLOCKED_BY_RESPONSENo
Content-Security-Policy: frame-ancestors 'self'net::ERR_BLOCKED_BY_RESPONSE, plus a console errorNo
Both, with DENY and 'none'net::ERR_BLOCKED_BY_RESPONSE, plus a console errorNo

The console message for frame-ancestors names the directive:

Framing 'https://notes.test:5301/' violates the following Content Security Policy directive:
"frame-ancestors 'none'". The request has been blocked.

The iframe still fires its load event, on an error page, so a page can’t use load to tell whether framing worked. 'self' blocked pages.notes.test too, because 'self' means the app’s exact origin, not its site.

The app doesn’t frame itself anywhere, so it sends both: frame-ancestors 'none' for browsers that understand CSP, and X-Frame-Options: DENY for any that don’t. The directive goes at the end of the chapter 5 policy:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app; report-uri /csp-report; report-to csp; frame-ancestors 'none'

Both must be HTTP response headers. CSP ignores frame-ancestors in a <meta> tag, and MDN says the same of X-Frame-Options.

One page is meant to be framed: the widget the partner shows on its dashboard. X-Frame-Options can’t name another origin, so that route drops it and sends only frame-ancestors https://partner.example:5302. Both sites framed the widget and a note page side by side:

== partner.example:5302
  /widget    loaded
  /notes/3   blocked: violates "frame-ancestors 'none'"
== attacker.test:5302
  /widget    blocked: violates "frame-ancestors https://partner.example:5302"
  /notes/3   blocked: violates "frame-ancestors 'none'"

The exception is an exact origin, for the same reason chapter 6’s CORS allowlist is: a pattern like https://*.partner.example trusts every subdomain the partner will ever have.

Why not a script

Before these headers existed, sites defended themselves with “frame-busting” JavaScript: if the page finds itself framed, it navigates the top window to itself.

<script>if (top !== self) top.location = self.location;</script>

Added to the app without any header, it did work against the plain attack page: the whole tab navigated to the note, so a real user would have seen the app instead of the prize page. Then mallory added one attribute to her iframe:

<iframe sandbox="allow-forms allow-scripts allow-same-origin" src="https://notes.test:5301/notes/3"></iframe>

A sandboxed frame may run scripts and submit forms, but it may not navigate the top window unless the sandbox says allow-top-navigation. Chrome blocked the frame-buster:

Unsafe attempt to initiate navigation for frame with URL 'https://pages.notes.test:5302/p/clickjack-sandbox.html'
from frame with URL 'https://notes.test:5301/notes/3'. The frame attempting navigation of the top-level window
is sandboxed, but the flag of 'allow-top-navigation' or 'allow-top-navigation-by-user-activation' is not set.

and the share went through again. The framing page controls the frame, so a script inside the frame can’t be the defense. The header is read by the browser before the page is ever shown.

Never over plain HTTP

Chapter 2 made the session cookie Secure, so it never travels over plain HTTP. The rest of the connection still can. If alice types notes.test into the address bar, the browser’s first request is http://notes.test/. Anyone on her network path can answer that request instead of the server, and keep her on a plain-HTTP copy of the site that proxies to the real one. That attack is SSL stripping, and a redirect from HTTP to HTTPS doesn’t stop it, because the redirect is in the very response the attacker replaces.

HTTP Strict Transport Security (RFC 6797) lets a site tell the browser to stop using plain HTTP for it altogether:

Strict-Transport-Security: max-age=31536000; includeSubDomains

After the browser sees this over a valid HTTPS connection, for the next max-age seconds it rewrites every http:// URL for the host to https:// before sending anything. includeSubDomains extends that to every subdomain. The RFC sets the boundaries: the header is ignored over plain HTTP, ignored on a connection with certificate errors, and never applied to IP addresses. When rewriting, port 80 becomes 443, and any other explicit port is kept.

The lab’s app speaks only TLS on port 5301, so a plain-HTTP request to that port gets a connection reset. That made the effect easy to see:

== 1. plain http before any HSTS
  -> GET http://notes.test:5301/login
  xx failed: net::ERR_CONNECTION_RESET
== 2. https visit, server sends Strict-Transport-Security
  -> GET https://notes.test:5301/login
== 3. plain http again
  -> GET http://notes.test:5301/login
  -> GET https://notes.test:5301/login  (after 307 Internal Redirect)
   location now: https://notes.test:5301/login

The 307 never came from the server. Chrome made it up and labeled it:

307 Internal Redirect {"Location":"https://notes.test:5301/login","Non-Authoritative-Reason":"HSTS"}

With includeSubDomains, a plain-HTTP link to the sibling host was upgraded the same way:

  -> GET http://pages.notes.test:5302/p/x.html
  -> GET https://pages.notes.test:5302/p/x.html  (after 307 Internal Redirect)

The weak spot is the very first visit, before the browser has ever seen the header, and every visit after max-age runs out. Browsers ship a built-in preload list of domains that are HTTPS-only from the first request. hstspreload.org lists the requirements: a valid certificate, a redirect from HTTP to HTTPS on the same host, HTTPS on every subdomain, and a header with max-age of at least one year, includeSubDomains and preload. It also warns that getting a domain off the list again takes months. Chapter 11 covers preloading with the rest of the TLS setup.

includeSubDomains also means every subdomain must actually serve HTTPS, including internal ones nobody remembers. That’s the usual reason to roll HSTS out with a short max-age first and raise it once nothing breaks.

A note that runs as a script

Notes can be published as plain text at /notes/<id>/raw, served as Content-Type: text/plain. Mallory published one whose text was JavaScript:

document.title = "script from a text/plain note ran"; console.log("ran as", document.domain)

Then she put a tag that points at it into a note she shared, in a version of the app whose CSP was an allowlist, script-src 'self':

<script src="/notes/4/raw"></script>

That policy allows any script from the app’s own origin, and /notes/4/raw is on the app’s own origin. When alice opened the note, Chrome ran it:

  -> GET https://notes.test:5301/notes/4/raw
  console.log: ran as notes.test
   document.title = script from a text/plain note ran

The server said the response was plain text, and Chrome ran it as JavaScript anyway. For compatibility, browsers execute script responses with many content types that aren’t JavaScript. Chapter 5 explained why allowlist policies fail; this is one more reason. Any URL on the origin that returns attacker-chosen text is a script source.

X-Content-Type-Options: nosniff makes the browser take the Content-Type at its word. For a request whose destination is script or style, it blocks the response unless the type is a JavaScript type or text/css. With the header added, the same page:

  -> GET https://notes.test:5301/notes/4/raw
  Refused to execute script from 'https://notes.test:5301/notes/4/raw' because its MIME type ('text/plain')
  is not executable, and strict MIME type checking is enabled.
   document.title = Notes

The header has a second effect. Without it, a browser may look at the first bytes of a response and decide it is HTML even when it’s labeled something else. That turns an uploaded file into a page that runs script. With nosniff, MDN says a text/plain response stays text even if it contains markup. The header only works if the labels are right, so the app serves user content with explicit, accurate types.

The nonce-based policy from chapter 5 already blocks this particular attack, because the injected tag has no nonce. The header costs nothing and also covers the cases CSP doesn’t: style sheets, and responses that would otherwise be sniffed as HTML.

What the URL tells the next site

Sharing a note with a username only works for people with accounts, so the app also offers a read-only link, /s/<token>, where a random token is the only secret. Anyone with the URL can read the note. That makes the URL itself sensitive, and URLs leak through the Referer header. When a page loads an image or someone follows a link, the browser may tell the destination which page the request came from.

Alice’s shared note contained a photo hosted on another site and a link to someone’s blog:

<p>Photo:</p><img src="https://attacker.test:5302/cat.png"><a id=l href="https://attacker.test:5302/p/x.html">my blog</a>

A visitor opened the link URL, the image loaded, and the visitor clicked the link. The attacker’s server logged the Referer of each request under three policies:

Referrer-Policy on the appImage requestLink click
None senthttps://notes.test:5301/https://notes.test:5301/
unsafe-urlhttps://notes.test:5301/s/6395a303...https://notes.test:5301/s/6395a303...
no-referrernonenone

With no header, the token didn’t leak. Chrome’s default policy is strict-origin-when-cross-origin: the full URL for same-origin requests, only the origin for cross-origin ones, and nothing when going from HTTPS to HTTP. MDN notes that this became the default in a spec revision in November 2020; the old default sent the full URL to any HTTPS destination. unsafe-url brings the old leak back, and some apps set it to keep analytics working.

The app sets strict-origin-when-cross-origin explicitly, so the behavior doesn’t depend on the browser, and no-referrer on the /s/ pages. A token in a URL is also visible in browser history and in server and proxy logs, so for anything more sensitive, a token doesn’t belong in a URL at all.

One more run showed the limits of a header. With no-referrer set on the page, the note’s image tag carried its own attribute:

<img referrerpolicy="unsafe-url" src="https://attacker.test:5302/cat.png">

The attacker’s server received Referer: https://notes.test:5301/s/6395a303a26f47dc99a17d03be42c65d. An element’s referrerpolicy attribute sets the policy for its own request and takes precedence over the page’s. Whoever controls markup controls the policy. The sanitizer from chapter 4 (nh3 0.3.7) drops the attribute; given the same note, it returned <img src="https://attacker.test/cat.png">, and it also added rel="noopener noreferrer" to links.

Features the app doesn’t use

The app has no use for the camera, the microphone, geolocation or payments. Any script that ends up running in its pages could still ask for them, and so could any page that frames the app, on the app’s behalf. Permissions-Policy turns features off for a document and everything it frames:

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

An empty list, (), means no origin may use the feature. self or a list of origins allows some, and an iframe’s allow attribute can only grant a feature within what the header permits. Chrome’s view of the login page, before and after:

before: [["geolocation",true],["camera",true],["microphone",true],["payment",true],["fullscreen",true]]
after:  [["geolocation",false],["camera",false],["microphone",false],["payment",false],["fullscreen",true]]

The calls failed differently. Before the header, getCurrentPosition returned User denied Geolocation, because headless Chrome turns down permission prompts. After it, the error was Geolocation has been disabled in this document by permissions policy, with a console report of the violation, and no prompt was ever possible. A real user would otherwise have seen a prompt that seemed to come from the app.

Windows, subresources and Spectre

The last group of headers came out of a hardware bug. Spectre, disclosed in January 2018, showed that code running in a process can read memory in that same process through speculative execution and a precise timer. For browsers, the W3C’s post-Spectre guidance states the consequence: any data that flows into a process hosting a given origin is legible to that origin. Same-origin policy is a rule in the browser’s code. Spectre goes around the code and reads the memory.

Browsers responded on two fronts. They slowed down the attack: performance.now() was coarsened and SharedArrayBuffer, which can be used to build a precise timer, was disabled. Chrome also moved sites into separate processes (Site Isolation). That keeps an attacker’s page out of the app’s process, but a page can still pull the app’s bytes into its own process: by loading a URL as an image or script (a no-cors request), or by keeping a handle to a window it opened. The three headers close those paths, and the third gives back the precise APIs to sites that want them.

The test page on pages.notes.test did three things: loaded /api/notes as an <img>, fetched it with mode: "no-cors" and credentials, and opened alice’s note in a new window on a click.

<button onclick="w = window.open('https://notes.test:5301/notes/3')">Open</button>
<img src="https://notes.test:5301/api/notes" onerror="console.log('img error')">
<script>
fetch("https://notes.test:5301/api/notes", {mode: "no-cors", credentials: "include"})
  .then(r => console.log("no-cors fetch: type=" + r.type + " status=" + r.status),
        e => console.log("no-cors fetch failed: " + e));
</script>

With none of the headers:

  -> GET https://notes.test:5301/api/notes   (the <img>)
  xx failed: net::ERR_BLOCKED_BY_ORB
  -> GET https://notes.test:5301/api/notes   (the fetch)
  console.log: no-cors fetch: type=opaque status=0
{"closed":false,"frames":0}

The browser already protects some of this. Chrome’s Opaque Response Blocking (ORB) refused to deliver the JSON response to the image request. The no-cors fetch got an opaque response, which page script can’t read, and DevTools recorded 0 bytes delivered for it. That protection is based on sniffing, which is one reason accurate types and nosniff matter. The window handle stayed live: w.closed was false, and through w mallory’s page can count the frames in alice’s window or navigate it somewhere else.

CORP and COOP

Cross-Origin-Resource-Policy: same-origin tells the browser not to hand the response to any other origin’s no-cors request at all. Cross-Origin-Opener-Policy: same-origin puts the app’s top-level pages into their own browsing context group, which severs the window link to any cross-origin opener. With both:

  -> GET https://notes.test:5301/api/notes
  xx failed: net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin blockedReason=corp-not-same-origin
  console.log: img error
  -> GET https://notes.test:5301/api/notes
  xx failed: net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin blockedReason=corp-not-same-origin
  console.log: no-cors fetch failed: TypeError: Failed to fetch
{"closed":true,"frames":0}

Both loads were blocked by policy instead of by guesswork, and the opener’s handle reported the window as closed even though alice’s tab was open. CORP is checked only for no-cors requests; CORS requests are still governed by chapter 6’s headers. same-site and cross-origin are the looser values, for resources such as images that other sites are meant to embed.

Cross-origin isolation

A page whose response has Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless) is cross-origin isolated. The browser can then guarantee that no other origin’s data is in its process unless that origin agreed, so it gives the page the precise APIs back. Measured on the app’s note page:

HeaderscrossOriginIsolatedSharedArrayBufferSmallest performance.now() step
Nonefalseundefined100.0 µs
COOP + CORPfalseundefined100.0 µs
COOP + CORP + COEP require-corptruefunction5.0 µs

The timer steps match MDN’s figures: 100 microseconds in non-isolated contexts and 5 in isolated ones.

COEP has a cost, and it falls on the page that sends it. require-corp means every cross-origin subresource must opt in with CORP or CORS. A note containing an ordinary image from another site broke:

  -> GET https://attacker.test:5302/cat.png
  xx failed: net::ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep
  console.log: cat.png blocked

With Cross-Origin-Embedder-Policy: credentialless instead, the image loaded, and the page was still isolated with a 5 µs timer. credentialless sends cross-origin no-cors requests without cookies, so whatever comes back is public anyway and can’t contain the user’s data.

The notes app doesn’t need SharedArrayBuffer or a precise timer, so it sends COOP and CORP and leaves COEP out. A page that does need them, such as one running WebAssembly threads, would add COEP and check that every embedded resource still loads.

The audit

A short script fetched the login page and printed each header the app should set:

WANT = ["Content-Security-Policy", "X-Frame-Options", "Strict-Transport-Security", "X-Content-Type-Options",
        "Referrer-Policy", "Permissions-Policy", "Cross-Origin-Opener-Policy", "Cross-Origin-Resource-Policy",
        "Cross-Origin-Embedder-Policy"]
r = requests.get(url, allow_redirects=False, verify=False)
for h in WANT:
    print(f"  {h:30} {r.headers.get(h) or '-- missing'}")

The app before this chapter’s changes (the version used for these runs had no CSP, so chapter 5’s policy doesn’t show here):

  Content-Security-Policy        -- missing
  X-Frame-Options                -- missing
  Strict-Transport-Security      -- missing
  X-Content-Type-Options         -- missing
  Referrer-Policy                -- missing
  Permissions-Policy             -- missing
  Cross-Origin-Opener-Policy     -- missing
  Cross-Origin-Resource-Policy   -- missing
  Cross-Origin-Embedder-Policy   -- missing
  Server: Werkzeug/3.1.9 Python/3.12.8

The headers are set in one after_request hook, so no route can forget them:

@app.after_request
def security_headers(resp):
    resp.headers["Content-Security-Policy"] = "frame-ancestors 'none'"    # appended to chapter 5's policy
    resp.headers["X-Frame-Options"] = "DENY"
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    resp.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()"
    resp.headers["Cross-Origin-Opener-Policy"] = "same-origin"
    resp.headers["Cross-Origin-Resource-Policy"] = "same-origin"
    if request.path.startswith("/s/"):
        resp.headers["Referrer-Policy"] = "no-referrer"
    if request.path == "/widget":                    # framed by the partner, and only the partner
        resp.headers["Content-Security-Policy"] = "frame-ancestors https://partner.example:5302"
        del resp.headers["X-Frame-Options"]
    if request.is_secure:
        resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    return resp

After:

  Content-Security-Policy        frame-ancestors 'none'
  X-Frame-Options                DENY
  Strict-Transport-Security      max-age=31536000; includeSubDomains
  X-Content-Type-Options         nosniff
  Referrer-Policy                strict-origin-when-cross-origin
  Permissions-Policy             camera=(), microphone=(), geolocation=(), payment=()
  Cross-Origin-Opener-Policy     same-origin
  Cross-Origin-Resource-Policy   same-origin
  Cross-Origin-Embedder-Policy   -- missing
  Server: Werkzeug/3.1.9 Python/3.12.8

COEP is missing on purpose. The Server line gives away the framework and Python version; a production server behind a reverse proxy usually replaces or drops it. That doesn’t fix anything, but there’s no reason to advertise versions.

One header is absent from this list on purpose too. X-XSS-Protection controlled a reflected-XSS filter in older browsers. MDN marks it deprecated, notes that the filter could itself create XSS holes in otherwise safe pages, and recommends CSP instead. A header-scanning tool may still flag it as missing. Each header in the list above was tested here against a specific attack, which is a better reason to keep a header than a score.

Where this leaves things

The browser side is done. The app can’t be framed except for the one widget the partner embeds, it can’t be downgraded to plain HTTP once a browser has seen it, its responses can’t be reinterpreted as scripts or pulled into another site’s process, its URLs don’t leak to other sites, and its pages can’t use powerful features. Together with the cookie attributes, CSRF tokens, output encoding, CSP and CORS of chapters 2 to 6, other sites can no longer turn alice’s browser against her.

Every one of these defenses is an instruction to a browser. None of them applies to a request that doesn’t come from a browser, and none of them changes what the server does with the input it receives. Mallory can skip the browser entirely and talk to the server with curl. The search box is a good place to start. One single quote in the query:

curl -sk -b jar-mallory 'https://notes.test:5301/search?q=%27'

came back as an HTTP 500 with a PostgreSQL error in the page:

unterminated quoted string at or near "' ORDER BY title"
LINE 1: ... FROM notes WHERE owner_id = 3 AND body ILIKE '%'%' ORDER BY...

Mallory’s quote ended up inside the SQL text, and PostgreSQL parsed it as part of the query. Chapter 8 follows that through to the database and to the other places where the server turns input into code.