CogniPrep generates PDF reports with @react-pdf/renderer. I have written before about where those documents are allowed to break across pages and about the 469KB chunk that only arrives when you click Download.
This post is about the failure mode underneath both of them: the download worked on my machine, every time, and failed in production, every time.
The symptom
next dev: click Download, get a PDF. Production: click Download, no file. Same code, same browser, same report data, and the only thing that differs between the two runs is a response header.
Whenever dev and prod disagree with that kind of consistency, the difference is not in your code. It is in something the environment hands your code, and for us that was one conditional token in a header:
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''} https:`,
'unsafe-eval' is in our policy in development because the dev tooling needs it, and deliberately not in production. I had read that line many times as "dev is more permissive about eval". It also means something I had not thought about: 'unsafe-eval' permits WebAssembly compilation. So the dev policy was quietly satisfying a requirement I did not know the PDF renderer had.
What react-pdf actually needs
react-pdf does not lay out pages in JavaScript. It uses Yoga, Meta's flexbox layout engine, compiled to WebAssembly. The module ships inlined as a base64 data: URL, which means two separate things happen at runtime:
- The code fetches that
data:URL. Under CSP, afetch()of adata:URL is checked againstconnect-src, notimg-srcorfont-src. - The code compiles the resulting bytes with the WebAssembly API, which
script-srcgoverns.
Our production policy had connect-src 'self' https: wss: and no data:, and a script-src with no WebAssembly permission at all. Two violations, one click.
The fix is two tokens:
// The PDF reports need two entries here. @react-pdf/renderer lays out
// pages with yoga, a WebAssembly module inlined as a base64 `data:`
// URL: it fetches that URL (`connect-src data:`) and compiles the
// bytes (`'wasm-unsafe-eval'`, which allows WebAssembly compilation
// and nothing else, so JavaScript eval stays blocked). Without them
// every "Download PDF" fails in production while working in
// development, where 'unsafe-eval' covers WebAssembly too.
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''} https:`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self' data: https: wss:",
'frame-src https:',
// ...
].join('; '),
}
Why wasm-unsafe-eval and not unsafe-eval
The lazy fix is to drop the NODE_ENV condition and ship 'unsafe-eval' everywhere. It works, and it hands every script on the page eval(), new Function() and string-argument setTimeout back.
'wasm-unsafe-eval' is the narrow version that exists for exactly this case: it permits WebAssembly compilation and instantiation and nothing else. JavaScript eval stays blocked. For a site whose main job is rendering content from its own content library, that distinction is worth the five minutes it takes to look up.
It is also the kind of token you cannot discover by reasoning about your own code. Nothing in our source calls WebAssembly.compile. The requirement comes from a layout engine three dependencies down, and the only reason I now know it is there is that the browser said so.
The data: in connect-src is the part that still looks wrong
Three of our directives mention data::
| directive | why |
|---|---|
img-src 'self' data: https: |
inline images, including generated chart data URLs |
font-src 'self' data: |
fonts the renderer embeds rather than requests |
connect-src 'self' data: https: wss: |
the new one, and the only surprising one |
data: in img-src is so common that it reads as boilerplate. In connect-src it is unusual enough that I wrote the comment above before I wrote the token, because the next person to audit this header will stop on that line, and they should. The honest justification is that it is a fetch of bytes the bundle already contains, so it grants no new reach: a data: URL cannot name a remote host, and anything that could plant one into a fetch() call already has script execution.
The general lesson, which is not about PDFs
Every conditional in a security header is a place where production runs a different program from the one you tested. Ours had one conditional, for one good reason, and it hid a dependency's requirement until the first production download, because that requirement happened to fall inside the extra permission.
Two things I now do differently:
-
Read dev-only tokens as a list of things you have not tested.
'unsafe-eval'in development means "no part of this app's WebAssembly,evalornew Functionusage is covered by a production test". That is a short list, but it is not empty. - A browser-side renderer deserves a production smoke test, not a unit test. We render a real PDF buffer in a unit test, which is genuinely useful and could never have caught this: Node has no CSP. The only thing that catches a CSP bug is a browser with the production header on it.
Where the reports come from
If you want to see what the documents this header protects actually are: a practice sitting on any of the 56 assessment providers we cover produces a session report, and the one-time Scores and Reports upgrade is the part that turns that into the downloadable version with the per-metric feedback in it. The assessment centre exercises produce the fourth document type, which is the one with candidate written answers in it and therefore the one most likely to run long enough to find a pagination bug.
Top comments (0)