PDFAirlock

← Blog · 2026-09-08

Your CSP can silently kill WebAssembly: a production postmortem

The first production deploy of PDFAirlock had a clean bill of health: 104 automated tests green, the full end-to-end suite passing in three browser engines against a local build of the exact bytes being shipped. Then I pointed the same e2e suite at the live URL, and the password tools were dead. Click “Add password”, nothing. No crash screen, no console explosion in the happy path — just an operation that never finished.

Nothing about the build differed. The same worker file, the same WASM binary, the same HTML. The only thing production had that my local test server didn't was headers.

The culprit is the security header we were proud of

PDFAirlock's whole pitch is that files can't leave your browser, and part of the enforcement is a strict Content-Security-Policy. The relevant slice looked like this:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; ...

Here's the part I didn't know: in Chromium, once you declare a script-src, compiling WebAssembly requires an explicit capability. Without it, WebAssembly.instantiate throws a CompileError — our qpdf engine (real qpdf, compiled to WASM, doing AES-256 encryption) could be downloaded but never compiled. The keyword that grants it:

script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval';

'wasm-unsafe-eval' is the WASM-only sibling of 'unsafe-eval': it permits WebAssembly compilation without also re-enabling JavaScript eval(). The name is scarier than the reality — for a site whose WASM is self-hosted and whose CSP forbids loading scripts from anywhere else, it's the correct, minimal grant.

Why every local test missed it

The local e2e suite runs against a plain static file server — which sends no CSP header at all. No header, no restriction, WASM compiles fine, 104 tests pass. The header only exists in production because the hosting platform injects it from configuration. The bug wasn't in the code being tested; it was in the delta between environments, and that delta is invisible by construction to any purely local test.

The same live run caught a second environment-only bug the same afternoon: the deployment platform skipped our npm lifecycle scripts and rebuilt from intermediate artifacts, silently dropping a file our offline mode depended on. Two ship-blockers, zero of them catchable locally.

What changed afterwards

  • The e2e suite accepts a base URL, and every deploy-affecting change ends with a run against production: PLAYWRIGHT_BASE_URL=https://pdfairlock.com npx playwright test. It's the same specs — real uploads, real downloads, real assertions on the output files — just aimed at the thing users actually touch.
  • The CSP is treated as code: it lives in the repo, and any edit to it is reviewed like a security change, because it is one — in both directions. Too loose leaks; too strict, as we learned, breaks.
  • One spec now exists purely to assert the product's core claim under the real CSP: process a file while recording every network request, and require that zero non-origin requests occurred.

The general lesson isn't about WASM. It's that headers, build pipelines, and CDN behavior are part of your program, and the only environment that runs your whole program is production. Test there — politely, automatically, after every deploy.

More on how the enforcement fits together: the PDFAirlock privacy model.

This post is from building PDFAirlock — PDF tools that run entirely in your browser and keep working with your wifi off. Free, no account, and every privacy claim is verifiable.