A HAR file is a record of network requests. It is also a compact archive of where someone has been, what their applications returned, and, sometimes usefully, sometimes unfortunately, the credentials that proved they were allowed to be there.
I recently opened a tool - that I routinely use for viewing HAR files since it's much nicer than Chromium devtools - and saw a message encouraging moving to a cloud-connected version of the product. While I'm sure they're above-board and aren't nefariously trying to steal anyone's credentials, I wanted a guarantee. And, quite frankly, I'm old and skeptical and maybe just a bit tired of needing a login for everything.
That made the privacy requirement for Snifflab unusually obvious: the trace should never leave the browser tab. The interesting work started when I tried to make that claim survive more than my good intentions.
The promise has two parts
Loading the Snifflab application makes one request: the HTML document. That request happens before the user chooses a trace. Everything the application needs - JavaScript, CSS, fonts, and icons - is inside the response.
After that, reading and inspecting a trace initiates no network traffic. The server never receives the file because the browser simply will not allow it to send anything anywhere.
Privacy is stronger when the application lacks the capability to betray the promise.
Deny by default, then permit only local data
The Content Security Policy starts with default-src 'none'. This is our safety blanket: unspecified resource categories inherit that none. The explicit exceptions grant access to content already inside the document or created in memory, not to a host.
| Directive | Production value | Purpose |
|---|---|---|
default-src | 'none' | Reject every unspecified resource category. |
script-src | Exact SHA-256 hash | Run only the script contained in this build. |
style-src | 'unsafe-inline' | Permit the grid’s inline positioning, but no network source. |
img-src | data: blob: | Render only embedded or in-memory images. |
font-src | data: | Use only embedded fonts. |
connect-src | 'none' | Block fetch, XHR, WebSocket, EventSource, and beacon. |
base-uri | 'none' | Prevent base-URL manipulation. |
form-action | 'none' | Block form submission as an exit channel. |
That distinction matters: not every source directive is literally 'none', but no source expression allows a network origin.
connect-src 'none' was not enough
My first pass left 'self' in the image, font, and style directives. The usual programmatic APIs were blocked, but a same-origin image URL could still encode trace data in its path. The browser would make the request under img-src, never consulting connect-src.
There was no actual exfiltration code. But connect-src 'none' did not prove that exfiltration was impossible: other CSP directives still permitted same-origin resource requests. A future script could have placed sensitive data in an image or other resource URL without violating connect-src.
The policy is compiled for the artifact
During development, the source page uses an intentionally looser development policy. After Vite produces the single HTML file, the build hashes every inline script and rewrites script-src to allow exactly those bytes:
script-src 'sha256-<hash of the script in this document>';
The same step removes the development-only 'self' allowances. This is done after bundling because only then do we know the precise script and the precise resources that will ship.
Then, a second step distrusts the first
A verifier reads the completed file and checks the properties we actually care about. It fails the build when:
- The CSP is missing or its deny-by-default directives change.
- The image, font, or style source sets contain anything beyond their exact local allowlists.
- The declared script hashes differ from hashes recomputed from the final document.
- A script, stylesheet, font, or CSS asset remains external.
- An unexpected HTTP URL appears outside a deliberately sanctioned link.
- Prefetch, preload, prerender, preconnect, or speculation rules are introduced.
- Source code constructs a navigation without an adjacent explanation for reviewers.
Snifflab does contain two outbound links: one to this site, and one to Stripe to allow generous users to show their appreciation in the form of money. Even these links are guarded: the verifier applies an allow-list and requires that they have noopener, noreferrer.
The pipeline runs that verifier against the artifact immediately before deployment. A browser smoke test then loads the same production bundle and exercises the real file-input and inspection path.
Published verifier
Inspect the shipped boundary.
Snifflab's application source and full build pipeline are not hosted publicly. This dependency-free checker is adapted from the portion of the verifier that reads the final HTML. It checks the exact CSP, recomputes every script hash, and rejects network-capable resources and URL literals.
curl --fail --silent --show-error https://snifflab.dev/ -o snifflab.html
node verify-snifflab-artifact.mjs snifflab.html
Anyone can run this exact check against the live artifact and get the same answer our pipeline gets. What you can't verify from outside is whether it runs as a gate on every deploy or whether we just happened to pass it this once. If we ever ship a build that fails it, though, this script will tell you.
A guarantee, within a threat model
The CSP is a runtime boundary enforced by the browser. The verifier is an executable statement of what Snifflab currently intends that boundary to be. Together they prevent an ordinary import, plugin change, or refactor from quietly adding a network dependency.
Of course, they do not bind a future maintainer. Someone can weaken the CSP and update the verifier beside it, just as someone can delete a failing test. What all this machinery buys is an explicit, reviewable decision instead of an accidental regression.
The guarantee is not immutable. It is, however, difficult to change without anyone noticing.
What we can't avoid
- The host sees the initial page request, including the ordinary connection metadata every web server receives.
- A compromised host could replace the page before it reaches the browser. A saved offline copy reduces that trust to the file itself.
- Browser extensions and a compromised browser sit outside the application’s boundary.
- Two explicit outbound links make requests when the user chooses to click them; however, they carry no trace-derived data or query string.
- The redaction tool uses fixed rules and remains a review aid, not proof that arbitrary hostile input contains no secrets.
- Snifflab’s source and build pipeline are private. The published verifier above covers what the shipped artifact can prove, but it can't reach anything upstream of that.
The useful pattern
A privacy claim written in prose can drift away from the product. By representing the privacy claim in browser permissions, enforcing it during the build, checking against the final artifact, and gating it in deployment, we have added a concrete check that will fail if we break our promise.
That's the pattern I want to carry forward: decide what the software should be incapable of doing, remove the capability, and make its return break the build.