skip to content

When a statement page's Content-Security-Policy allows `script-src 'self' https://static.example.net` and that host echoes a caller-supplied callback name into a JavaScript response, what can an injected script tag achieve?

level: seniorimportance: must knowfreq 56%

answer

  1. the policy matches the request, not the reply
  2. the host said yes, not the file
  3. caller-chosen callback is caller-chosen code
  4. runs in the page's own origin
  5. an entry grants every response that host produces

basics

~20 s

A source expression matches the URL a script is fetched from, never the bytes that come back. An injected script tag pointing at that endpoint matches the allowlisted host, so the attacker-chosen callback name executes in the page's own origin.

solid answer

~50 s

The policy is satisfied by the *request*. An injected `<script src="https://static.example.net/v1/points?callback=...">` matches the `host-source` exactly, so the fetch is permitted, the response is served as JavaScript, and it runs with the statement page's origin and its authenticated session. The attacker picks the callback name, so the attacker picks the leading executable text of that response - the allowlist entry has effectively granted arbitrary code execution. The policy still does useful work: an inline `<script>` element is still refused, `javascript:` links are still refused, and hosts outside the list are still refused, so the attacker had to find this specific route. The audit rule is that an allowlist entry is a grant to **every response that host can be made to produce**, including ones that reflect caller input, and no amount of lengthening the list fixes that.

code

html · 1 line
html
<script src="https://static.example.net/v1/points?callback=attackerPayload"></script>

go deeper

for a junior

The point to hold on to is that the policy checks where a script is fetched from and never what comes back, so trusting a host means trusting everything that host can return.

for a middle

Explain the match itself: the injected element's URL satisfies the host-source, the response is served as script, and it executes in the page's origin - no rule was broken anywhere.

for a senior

Show the audit: per allowlisted host, ask whether it reflects input into a script response, serves user-uploaded content, or redirects, and report the entry rather than the endpoint.

for a principal

The tradeoff to own is that any host allowlist is a standing dependency on other teams' endpoints staying non-reflective, which is a commitment nobody signed up for and nobody re-checks.

## What a host entry in a source list actually grants A fetch directive answers exactly one question, once per request: does the URL this document is about to fetch match some expression in this source list? If it does, the fetch proceeds and the response runs. Nothing downstream re-examines that decision. **The Content-Security-Policy never looks at the response body**, its `Content-Type`, who wrote it, or whether a query string shaped it. So `https://static.example.net` in `script-src` does not mean "the scripts that host publishes". It means *every sequence of bytes that host can be induced to return under a URL a page can construct*. ## Why a callback endpoint is executable code An endpoint that wraps data in a caller-supplied callback name exists to be loaded as a script element, so it is served as JavaScript, and the caller decides the first tokens of it. The injected markup is a single element that the policy is perfectly happy with: 1. The attacker injects `<script src="https://static.example.net/v1/points?callback=PAYLOAD">` into the statement page. 2. The browser matches the URL against the `host-source` - scheme, host and port all match, and no `path-part` was written, so any path on that host is in scope. 3. The fetch proceeds, and the response comes back as JavaScript beginning with the attacker's chosen text. 4. It executes in the statement page's own origin, with the authenticated session, able to read the rendered balance and call the page's own endpoints. The policy was never bypassed in the sense of being broken. It was **satisfied**. ## What the policy still stops Stating the limit matters as much as the hole, because it is what decides how hard the attacker had to work: - An injected inline `<script>` element is still refused, since no inline keyword is present. - An injected `javascript:` target is still refused for the same reason. - A `<script src>` for a host nowhere in the list is still refused. - Nothing here grants string compilation; that is a separate keyword. So the attack needed an allowlisted host with a reflecting endpoint. It is a failure of the allowlist, not of the header. ## Reading an allowlist adversarially | Entry in `script-src` | What it really grants | |---|---| | `*` | script from any URL with a host - which is why it does not cover `data:` or `blob:` | | `https:` | script from every HTTPS host on the internet | | `https://*.usercontent.example` | script from any subdomain, including ones serving arbitrary uploaded files | | `https://static.example.net` | every response that one host can be made to produce, including reflected ones | | `data:` | a script whose body an attacker writes into the URL itself | The `data:` row is worth its own note: a `host-source`, wildcard included, matches on host, and a `data:` or `blob:` URL has no host to match. That is why a bare `*` does not cover them and why naming the scheme does. A list that reads `script-src 'self' data:` is strictly more dangerous than it looks, because an injected script element can carry its whole payload inside its own `src`. ## The three questions an audit asks per entry 1. **Can this host be made to reflect caller input into a response served as script?** A callback endpoint is the classic case, but any endpoint that echoes a parameter into a JavaScript or JSON-with-callback response qualifies. 2. **Can this host serve content someone else uploaded?** If arbitrary users can place a file anywhere on it, the entry is a grant to those users. 3. **Can this host redirect?** A redirect changes which URL is finally fetched, and the matching rules treat a redirected request differently from a direct one. If the answer to any of the three is yes, the entry grants more than the name in it suggests - and the fix is not a longer or more precise list of hosts. The lesson an interviewer is looking for is that **a host allowlist constrains where a script came from, and an attacker only needs one allowlisted host willing to say what they choose.**

  • Does a bare `*` in `script-src` cover a `data:` script URL?
    No. A `host-source`, wildcard included, matches on the URL's host, and `data:` and `blob:` URLs have no host to match - so only an explicit `scheme-source` such as `data:` names them. That is a rare case of the wildcard being narrower than a shorter-looking list: `script-src 'self' data:` permits a script whose entire body an attacker writes into the URL.
  • The endpoint only accepts callback names made of letters and digits. Is the bypass closed?
    It is narrowed, not closed. Arbitrary statements can no longer be written, but the attacker can still name any function reachable in the page and have it called with data the endpoint returns, and a validation that is one regular expression away from being wrong is not a boundary you want a policy resting on. The finding stands: the host should not be in a script source list.
  • Why does lengthening or tightening the host list not fix this?
    Because the property being exploited is not which hosts are listed but that listing a host grants everything it can be made to return. Each added entry is another surface to audit for reflecting endpoints, uploaded content and redirects, and the audit has to be redone whenever any of those hosts changes.

A host allowlist is a guest list of buildings, not of people: the doorman checks which building your envelope came from, never what is written inside it. Anyone who can persuade a listed building to hand out an envelope in their words is already inside.

saying these in an interview costs you the question

  • Thinks the policy checks the response body or its content type
  • Says the endpoint is safe because it returns data, not script
  • Believes an allowlisted host can only serve its published files
  • Claims the injected script runs in the other host's origin
  • Assumes a longer, more precise host list closes the hole