A HAR file is an archive of network requests. It’s also a record 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 gave me the insipriation for the Snifflab privacy requirement: the trace should never leave the browser tab. The interesting work started when I tried to make that "never" actually mean never.

Restricting what it can do

Loading the Snifflab application makes one request: the HTML document. That request happens before the user chooses a trace. Everything else the application needs - JavaScript, CSS, fonts, and icons - is inside this one response.

After that, the application initiates no network traffic. The browser blocks the ordinary connection and resource-loading channels, the build ensures that the site doesn’t use any navigation and WebRTC primitives that CSP can’t reliably disable, and the actual response from the web server is checked for reporting endpoints and browser-managed reporting policies.

Privacy is stronger when it’s built in to the application.

Deny by default, and 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 any remote host.

DirectiveProduction valuePurpose
default-src'none'Reject every unspecified resource category.
script-srcExact SHA-256 hashRun only the script contained in this build.
style-src'unsafe-inline'Permit the grid’s inline positioning, but no network source.
img-srcdata: blob:Render only embedded or in-memory images.
font-srcdata: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 expression allows a network origin. Nothing leaves the browser.

But 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 could theoretically make a request under img-src, ignoring 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.

CSP also is not a universal network-off switch (although that would be nice). Browsers permit document navigation outside connect-src, and WebRTC does not have a reliable cross-browser blocking directive. Snifflab doesn’t use either capability, so the build can (and does) treat their appearance as an error.

The policy is applied by the build

During development, the source code 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>';

That same step also removes the development-only 'self' allowances. This is done after bundling, because only then do we know the actual script and resources that will ship.

Then, static analysis to verify it

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.
  • The shipped script contains WebRTC, programmatic navigation, or browser-managed reporting primitives.
  • The known dynamic-link inventory changes from its single local blob: download.
  • 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.
  • The hosting rules stop removing report-producing or resource-hint headers, or weaken the browser-capability policy.

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 build 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

You can check my work

Snifflab’s application source and full build pipeline are not hosted publicly, but this dependency-free checker is adapted from the artifact portion of the build gate. It fetches the live page, checks its response policies, recomputes every script hash, inventories the one local blob: download, and rejects reporting endpoints, browser-managed reporting APIs, WebRTC, programmatic navigation, network-capable resources, and unexpected URL literals.

node verify-snifflab-artifact.mjs https://snifflab.dev/

Anyone can run this check directly against production and get the same answer our build pipeline gets. If the production site ever drifts outside the published invariants, the checker will fail.

The practical guarantee

The CSP is a strong runtime boundary, at least for the channels it can govern. The verifier covers the practical gaps by rejecting navigation, WebRTC, and browser-managed reporting primitives that Snifflab doesn’t need, then checking the live response for reporting endpoints and report-producing policies. Together they prevent an ordinary import, plugin change, or refactor from accidentally weakening the privacy guarantee.

Of course, someone who has access to the source and publishing channel can replace both the application and its checks. The useful guarantee is narrower and practical: weakening the boundary is difficult to do accidentally, and produces an explicit, reviewable change instead of an easy-to-miss 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 takeaway

A privacy claim is just a claim - it can drift away from the product. By representing as much of the claim as possible in browser permissions, rejecting the remaining capabilities during the build, checking the final response independently, and gating deployment, we have added concrete checks that fail if we break our promise.

That’s the pattern I want to carry forward: decide what the software should be capable of doing, and make sure it can’t do anything else.