Chapter 6 of 7 · 14 min read
Automatic fetch surfaces
On this page
An automatic fetch surface is any part of your product that issues an outbound request because content arrived, rather than because a person asked for one. The rendered image from the first chapter is the obvious member of the family. The one teams miss is link unfurling. Someone pastes an assistant reply containing a plain URL into a chat product, and before anyone clicks, a preview card appears — title, description, a thumbnail — because the chat surface unfurled the link. To build that card, something fetched the URL. Not the browser this time, and not a tool the model called; the platform did it, server-side, the moment the message was posted. If that URL carried a canary, the unfurl request carried it out, and nothing you built was in the path.
That is the shape of every surface in this chapter. Something in the path from "the model produced output" to "the output is displayed and stored" reaches out, and the reaching-out is a feature somebody shipped for good reasons. You have hardened the renderer and bounded the tools; these are the exits that neither of those touches, because they are neither markup your renderer builds nor a tool in your manifest.
Build the list by asking who fetches, not what you coded
Unfurling repays taking apart, because knowing its steps tells you which of them
you could interrupt and which you could not. When a message is posted, the
platform scans its text for anything URL-shaped. For each match it issues a
GET from its own servers — not from any reader's browser — and parses the
returned HTML for a small set of metadata tags, most commonly the Open Graph
tags a page publishes to describe itself: <meta property="og:title">,
og:description, og:image. It then fetches whatever og:image names, so the
card has a thumbnail, and caches the result so that the same link posted again
costs nothing. The canary leaves during the first of those steps, before any
part of the card has been shown to anybody.
Two consequences of that sequence are easy to read past. The request originates
in the platform's address space rather than in a reader's browser, so nothing on
the reader's side is in the path: no Content-Security-Policy header of yours,
no image blocking, no browser extension. And the trigger is a bare URL sitting
in text, which means the renderer setting that stopped your own markdown library
from turning bare URLs into links buys you nothing at all here. Your renderer's
involvement ended when the text was stored. The platform's linkifier is a
different program, run later, reading the same characters and reaching its own
conclusion about them.
Link unfurling is the surface teams miss most often, and the reason is instructive: it is not in your renderer and it is not in your tool manifest, the two places people look. It sits in the messaging platform, or a preview microservice, or a vendor SDK, and it fetches automatically to make the product feel nice. It also re-creates, on the server, exactly the zero-click property that blocking images removed on the client — a link the user never touched still triggers a fetch, because unfurling clicks it for them. Any inventory built by reading the code you wrote will miss it, because the request originates in code you did not write.
The inventory question is precise: what does your product fetch or transmit that the model influenced, without the user asking for it. "Without the user asking" is the filter that catches the dangerous surfaces, because a fetch a user explicitly initiated has a human in the loop and a fetch that happens on render or on receipt does not. Enumerate by walking the message's life from the moment the model produces output to the moment it is fully displayed and stored, and at each step ask whether anything reaches out.
| Surface | What triggers the fetch | Who owns it | How you test it |
|---|---|---|---|
| Renderer image/SVG load | message renders in the browser | your frontend team | canary URL in an image; watch your collector's log |
| Link unfurl / preview card | message posted or received | platform / messaging team, often a vendor | post a message with a canary URL; watch collector + platform egress |
| Image proxy / cache warmer | image referenced, proxied for privacy or speed | infra / platform team | reference a canary image; check whether the proxy fetches it |
| Redirect follower | an allowlisted URL returns a 3xx | your fetch/proxy code, or the browser | serve a 302 to a canary host; see if the second hop fires |
| Server-side markdown-to-PDF / export | user or system exports the conversation | your export service | export a conversation containing a canary image; watch the log |
| Tool side effects | the agent calls a tool that fetches or writes | your agent team + each tool's owner | canary in a tool argument; read the egress proxy log |
| Webhook / notification fan-out | a message triggers an outbound notification | your notifications team | canary in content that fans out; watch the webhook destination |
| Third-party embeds / oEmbed (a standard by which one site asks another for an embeddable version of a URL) | content matches an embeddable pattern | the embedding vendor | canary URL matching an embed pattern; watch vendor egress |
The table's value is its middle column. Severity — which of these reaches an arbitrary attacker-chosen destination — is one axis, and it is the one people rank on. Ownership is the axis that decides what you can actually ship, because a surface a vendor owns is a support ticket and a contract negotiation, and a surface your frontend team owns is a pull request. Two surfaces with identical severity can be a week apart or a quarter apart depending on who has the keys, and an inventory that records only severity hides exactly the information you need to plan.
Two properties have to hold together before a row in that table is a channel, and separating them keeps the inventory from swelling into a list of everything your product does. The first is that the model influences what gets fetched: a surface that only ever requests fixed addresses your code chose carries nothing an attacker picked. The second is that the fetch is automatic, which is the "without the user asking" filter, and it decides whether the leak is certain or merely available. A surface with the first property and not the second is the link case from the opening chapter, still an attack and one with a human veto in it. A surface with the second and not the first is a cache warmer fetching your own assets, which is noise. Both together is the zero-click channel, and the inventory exists to find every place they coincide.
Testing each surface is the same primitive throughout, which is why it is worth building once: a canary string — unique, planted where the model can be induced to emit it, and worth nothing to anyone but you — with a listener at every destination that string might reach. You do not have to guess whether a surface fetches; you make it fetch a URL only you know about, and you watch. If the canary shows up at your collector, the surface transmits; if it does not, either the surface does not fetch or your injection did not reach it, and distinguishing those two is the subject of the final chapter. The point here is that the test is cheap and uniform, so the hard part is not testing the surfaces but knowing they exist to test.
One refinement makes the testing column trustworthy rather than merely cheap. Run every row with the same canary and an arrival tells you that something fetched, not which thing, and the surfaces in this table overlap heavily: a URL the renderer loads is also a URL an unfurler may see, and an export service may re-render the same message a third time. Give each surface its own canary — a distinct string, or the same string under a distinct path — and every arrival identifies its own row with no further reasoning required. The cost is a table of strings. The return is that one run yields a per-surface result, rather than a single bit you then have to disambiguate by switching rows off and running again.
Because the hard part is discovery rather than testing, it pays to build the list from more than one direction, since each method misses what the others catch. Reading your own code finds the surfaces you wrote and none of the ones you inherited. Reading a full egress capture from a staging environment — every outbound connection your product's infrastructure makes while processing an assistant reply — finds surfaces regardless of who wrote them, because a link unfurler you did not know about still shows up as a connection leaving a host you own, and this is often how a platform-owned fetch first becomes visible to the team that has to defend it. Reading your vendors' documentation finds the surfaces that fire on conditions you have not triggered yet — an oEmbed provider that fetches only for URLs matching a pattern, a preview service that activates only in certain message types. The inventory you trust is the union of code review, egress capture, and vendor docs, because any one of them alone has a characteristic blind spot, and the surfaces that leak are disproportionately the ones a single method would miss.
"Read a full egress capture" hides a decision, so it is worth saying what one consists of. You record, at a point every outbound connection has to cross — the NAT gateway, the cluster's egress node, an agent running on each host — which destinations your infrastructure contacted while a given operation ran. What you get back is not the payload. Nearly everything is encrypted, so per connection you see the destination address, the port, the timing and the volume, plus the hostname the client announced in the clear at the start of the TLS handshake. That last field is called Server Name Indication, and it exists so that a server hosting many sites knows which certificate to present; it is readable by anyone in the path precisely because it has to be read before encryption begins. For this exercise it is enough, because the question being asked is which hosts your product contacts on its own initiative, and a hostname nobody expected is the finding. A subsystem you inherited announces itself in that capture exactly the way a subsystem you wrote does, which is the property that makes the method worth its setup cost.
The surfaces outside your codebase
Several rows above are owned by someone else, and that is the part the inventory
exists to surface. Link unfurling frequently lives in the messaging platform — if
your product is built on a chat framework or posts into Slack, Teams, or a similar
surface, the unfurl is theirs and it fetches URLs your model produced with no code
of yours involved. Image proxies are often an infrastructure team's privacy
feature: they proxy remote images so the end user's browser never contacts a third
party directly, which is good for user privacy and means the proxy now makes the
outbound request, carrying whatever is in the URL to wherever it points. The
mechanism is rewriting rather than blocking, which is what makes the effect easy
to misjudge. Your product replaces <img src="https://example.test/p.png"> with
a URL pointing at your own proxy and carrying the original address as a
parameter, usually next to a signature so that strangers cannot use the proxy as
an open relay for their own traffic. The reader's browser fetches from your
proxy; your proxy fetches from example.test; the reader's address is never
disclosed to example.test at all. The request was relocated rather than
removed, and it now originates from a machine whose network position is
generally better than a reader's. The signature stops other people abusing the
proxy, and it has nothing to say about a URL your own product signed on the
model's behalf. Redirect
following may be the browser's behaviour, outside anyone's code. Third-party embed
providers fetch on pattern-match. Each of these transmits on the model's behalf,
and each is a conversation with a different owner rather than a change you merge.
The reason to name owners explicitly is that it changes what "fix" means per row. For a surface your team owns, the fix is code: strip the query, block the scheme, proxy through your own fetcher. For a platform-owned surface, the fix might be a configuration flag the platform exposes — disable unfurling for assistant messages, if the platform lets you scope it — or it might be a feature request with a long lead time, or it might be a compensating control you place upstream because you cannot touch the surface itself. You cannot know which until you know who owns it, and you cannot ask the right team until the inventory says which team that is.
Where an inventory stops being a statement about safety
There is a limit worth stating plainly, so the inventory is not mistaken for completeness. The list is only as good as your knowledge of your own stack and your vendors' behaviour, and both change under you — a platform ships unfurling in an update, a new SDK adds an image proxy, a partner enables embeds. The inventory is a snapshot that decays, and the maintenance is re-running it when the stack changes, the same shape of upkeep the allowlist needed two chapters ago.
It also tells you which surfaces can transmit, not which ones did on a given attack, and those are different questions. Knowing that nine surfaces can fetch does not tell you which one carried the canary out last Tuesday, or whether the others stayed quiet because you blocked them or because the injection never reached them. An inventory is a map of exits; it is not a record of which exit was used, and it cannot distinguish a door you locked from a door nobody tried.
Those two limits set up the last piece of the topic. The inventory is the list of places a canary could go, built from code review, egress capture and vendor docs, with an owner beside each row and a test beside each owner. It is what converts "we hardened the renderer" into a claim with a boundary: these surfaces, tested this way, owned by these people, as of this date.
Answering the other question — which exit carried the data on a particular attack, and whether the exits that stayed quiet were closed on purpose — requires planting the canary and following it to the first boundary that let it through. That is where the topic ends.
Data exfiltration channels — read the current version at https://daviddominguez.dev/learn/en/data-exfiltration-channels/automatic-fetch-surfaces