AppdorTry it

The marketing page that loads nothing from anyone else

Privacy · 5 min read

Open the network tab on a SaaS landing page and you will usually learn more about the vendor than the page meant to tell you. A font from one CDN, an icon set from another, three analytics beacons, a chat widget, a script that loads another script. Every one of those hosts learns the IP of everyone who visits.

Our marketing page makes one third-party request, and only if the visitor asks for it. Here is how that is held in place, because the interesting part is not the claim — it is what stops the claim from quietly becoming false.

The policy

img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'

The font and the icon set are npm dependencies — @fontsource-variable/inter, @fontsource/poppins and @fortawesome/fontawesome-free — imported by src/styles/main.css and served from our own origin.

The icon set used to be a cdnjs <link> in <head>. Four separate problems, none of them visible in a screenshot: a third-party round trip before first paint; cdnjs learning the IP of every visitor on every page view; a service-worker product whose icons all vanish without a network, which is not what offline-capable means; and a supply chain where whatever that host serves, the browser executes as CSS.

The guard, which is the actual subject

A CSP and a vendored font are one-time fixes. Re-adding a CDN <link> is a one-line change that nothing else would object to — so the thing worth building was not the fix but the test that keeps it.

tests/assets-self-hosted.test.js reads index.html and fails on any absolute http(s) URL that the page would fetch, and separately on any stylesheet that @imports a remote one. It is deliberately broader than "no cdnjs", because the next one will be a different host.

It also holds two rules that are about payload rather than privacy: FontAwesome must be a dependency rather than a link tag, and the all bundle is never imported — one word re-adds the regular weight and its font file, which is exactly the payload a previous change removed.

Where it bit, this week

We were approved on SaaSHub and wanted their badge in the footer. Their embed snippet does what every badge snippet does: an <img> pointing at their CDN.

That image cannot load here. img-src 'self' refuses it, and the footer two lines below says every asset on this page is served from this origin — no CDN, no third-party font, no remote script. So the badge PNG is vendored into public/media/badges/ and served from our origin; only the link out carries SaaSHub's own tracking parameters, which is what their snippet asks for.

That link was then the first absolute URL in index.html, and the guard failed it. The interesting question is whether that was the guard being right.

It was not, quite. The subject of that test is assets the page fetches — a remote stylesheet, a remote font, a remote script. An <a href> fetches nothing: the browser makes no request until somebody clicks, and the visitor's IP reaches that host only because they chose to go there. So the rule now reads the tag name and exempts <a> alone. <link>, <script> and <img> still fail on any remote host, and that was checked against all three rather than assumed — the exemption is narrow because a guard you widen to make your change pass is not a guard any more.

The one exception, and why it is one

The CSP permits googletagmanager.com in script-src, so the honest version of "loads nothing from anyone else" needs a sentence about analytics.

The operator's Google tag used to be an inline <script> in <head>, where Google's install note asks for it. It ran on one condition — a well-shaped measurement ID — and on nothing else. Not the consent banner, not a stored decline, not Do Not Track, not Global Privacy Control. A visitor who pressed Decline still had googletagmanager.com fetched and a hit sent before the bundle had parsed.

That was not merely a gap. It was a gap the product had already promised was closed: the privacy policy says a DNT or GPC signal is always honoured and overrides stored consent, and that without consent nothing is collected. Both were true of the built-in pipeline and false of that tag. Two mechanisms, one promise, and a reader has no way to tell which one a deployment is running.

The tag now asks the same isAnalyticsEnabled() that every other event asks, reading the same stored decision and the same privacy signals, and it loads after the entry parses — for a visitor who already opted in, or immediately on Accept for one deciding now. Later than before, and for most visitors never, which is what consent means.

It is an import rather than a copy of the gate pasted back into the markup, because a second reading of "may we measure this person" is a second thing to keep in step, and the direction it drifts when somebody renames the storage key is open.

Why any of this matters to a buyer

Because it is checkable, and almost nothing else on a vendor page is.

You cannot audit our retention policy from outside. You can open the network tab on the landing page and count the hosts, and you can do the same on the product itself. A vendor who takes the easy CDN on the page they control completely is telling you what they will do with the parts you cannot see.