DEV Community

Alex
Alex

Posted on

Your AI wrote the HTML. Now decide what that page is allowed to talk to.

Locking down who can open a page is the part everyone thinks about. You put it behind auth, you invite people by email, you set an expiry. Done.

Then the page loads and starts making requests you never looked at.

This is the part I got wrong for a while, and I only really got pushed on it last week when someone on Indie Hackers said, roughly: private mode is only real if auth gates every asset before any bytes leave the server, and generated HTML can still leak through embedded data, public JSON, images, and third party scripts. They were right, and the second half of that sentence is the interesting half.

Why AI-generated pages make this sharper

When you write a page by hand, you know what is in it. When you ask a model for "a dashboard for this data" you get back something that works, and somewhere in it there may be a font from a CDN, an analytics snippet it added out of habit, a chart library pulled from unpkg, an image hotlinked from wherever the model saw one. None of that is malicious. It is just the model producing the most common shape of the thing you asked for.

Now put that page behind a private link and hand it to a client. Every one of those outbound requests is a third party learning that someone opened your private page, and roughly when. An image URL alone can carry data in its query string.

So "private" has two halves:

  1. Inbound: who can get the bytes.
  2. Outbound: what the bytes can reach once they run.

Most sharing tools solve the first and quietly ignore the second.

The inbound half, briefly

For completeness, because the outbound half only matters if this part holds. Opening a site mints a short-lived signed token, and every sub-resource is served under that same token. There is no separate unprotected asset URL sitting on a CDN. On a gated site the token lives 120 seconds, so a copied asset URL is dead almost immediately. Expiry is checked before a token is ever minted, and on the data path too, so an expired link cuts off stored data the same way it cuts off content. Responses are private, no-store.

That is table stakes. Now the interesting part.

The outbound half: default deny, then approve out loud

The rule I landed on is that a published page can only load its own bundled assets unless the owner says otherwise. Concretely, the content renders in a sandboxed, cookieless, per-site origin under a CSP where script-src, style-src, font-src, connect-src and frame-src are 'self' only, and each approved origin is woven into those directives explicitly. No wildcards. The host pattern is validated strictly before it ever reaches a CSP string, because a source expression is just text and text is injectable.

Default deny is easy to write and horrible to ship, for one reason: it fails silently. The browser blocks the font, the page renders slightly wrong, and the owner has no idea why. So the deny has to come with a mouth. At publish time the files are scanned for external origins and the owner is told which ones were found and would be blocked. They approve the ones they meant, and the rest stay blocked. The approved set lives on the site as a list you can add to and remove from later, not a one-time dialog you clicked through.

Two honest limits on that scan. It is regex over the common static shapes (a CDN script tag, a fonts link), so anything assembled at runtime slips past it, and it does not look at images at all, because img-src is open anyway. It exists so nobody ships a half-rendered page. The CSP is the thing that enforces. The scan only tells you what the CSP is about to do, and it is best effort at that.

The framing I got back from that same thread, which I liked better than my own: approved hosts should read as capabilities. "This page can contact these three origins." That is a sentence a non-technical owner can actually evaluate. A publish-time warning is a moment; a capability list is a property of the thing.

The gap I have not closed

img-src still allows any https: origin.

That was a convenience call, because pages hotlink images constantly and blocking them made the first-run experience feel broken. It is also, precisely, the side door: an image request is an outbound request, and its URL is a channel. Tightening it to the same approve-per-origin rule as everything else is the obvious fix and it is the top thing on my list. It means teaching the publish scan to look at images too, which it does not do today. I am writing this down here partly so it stays uncomfortable.

The failure test worth stealing

The best thing to come out of that whole exchange was a test, not a feature: can an expired page still trigger an outbound request from cached HTML?

It cuts through the marketing, because it does not ask what your policy says, it asks what a copy of your page does after you have revoked it. And the honest answer has a boundary in it. Anything we serve, we can cut off: page, assets, stored data, all gated before a token exists. But CSP is a response header. It travels with the response, not with the file. If a viewer saves the page to disk, that saved copy is out of reach of any expiry you set, and it will happily make whatever requests were baked into it.

My first instinct was to write that this argues for tightening the outbound half at publish time rather than at serve time, on the logic that what you never allowed into the document cannot be triggered later from a copy of it.

That is wrong, and the reason is worth spelling out, because it is the trap. Tightening at publish, in a system like mine, still means adjusting a header that gets sent at serve time. The document itself is never rewritten. The <img src="https://..."> stays exactly where the model put it, so a saved copy fires it regardless of what the policy said while the page was live.

The only design that actually survives the test is rewriting the document at publish: inlining or proxying every external reference so there is no foreign URL left in the file to fire at all. That is a much bigger change than the img-src fix, it breaks things people reasonably expect to work, and I have not built it.

I am leaving the wrong version above rather than quietly deleting it, because it is the more natural thought, and if you are designing this you will probably have it too. It sounds like an architectural principle. It is really just a header.

If you are building something similar

Three things I would do again:

  • Make the deny default and the approval explicit, per origin, no wildcards.
  • Scan at publish and tell the owner what you are about to block, in plain words. Silent security gets ripped out by the first user who thinks your product is broken. Then stay honest with yourself about what the scan misses, because a convenience feature starts feeling like a guarantee surprisingly fast.
  • Write the gap down where users can see it. It is worse marketing and much better engineering.

I build Thryvate, which publishes what you make with AI as a private hosted site, so this is the problem I stare at. But nothing above is specific to it. If your AI is generating HTML that other people will open, you own both halves of the word private, whether or not you have looked at the second one.

What is the outbound story where you work? I am curious whether anyone has actually shipped strict img-src without the support burden eating them.

Top comments (0)