- Status: Accepted
- Date: 2026-07-29
- Deciders: Maintainer + Claude Code
- Context tags: security, csp, wasm, ci-gates
WorldScript Studio advertises a "fully browser-native 4-layer local inference engine" β WebLLM, ONNX Runtime Web, Transformers.js, DuckDB-WASM, plus Whisper-STT and Kokoro-TTS β as its core differentiator. Every one of those is WebAssembly-based.
From 2026-05-27 (faad8f0, feat(security): Phase 0 hardening) to 2026-07-29, script-src on
every one of the 5 deployment surfaces (index.html meta tag, vercel.json, public/_headers,
nginx.conf Γ3 blocks, src-tauri/tauri.conf.json) was 'self' with no 'wasm-unsafe-eval'.
WebAssembly.instantiate/compile are classified under CSP's 'unsafe-eval' restriction unless
'wasm-unsafe-eval' is explicitly present. The result: WebAssembly.instantiate was blocked in
every Chromium browser, on every deployment surface, for two months. The advertised local-inference
stack never functioned in production.
The same commit that introduced script-src 'self' also added an inline <script> at
index.html:96 (the aurora-disabled low-end-hardware check) with no nonce or hash β a second,
independent violation, directly contradicting that commit's own CSP-strategy comment three lines
above it ("script-src: nur eigene Skripte β¦ kein inline nΓΆtig" β "no inline needed").
Why no test caught this for two months: the existing CSP tests
(tests/unit/csp.test.ts, tests/unit/deploymentHeaders.test.ts) assert consistency between the
5 deployment surfaces, never functional correctness of a directive. Four identically-broken
CSPs pass those tests with a clean bill of health. scripts/smoke-prod-build.mjs loads the real
production build in headless Chromium β the right place to catch this β but only listened for
pageerror. CSP violations never fire pageerror; they surface as console warnings and
securitypolicyviolation DOM events, neither of which the script observed.
Separately, no CSP surface had a frame-src/child-src directive, so both fell back to
default-src 'self', which blocks blob: iframes. components/BinderPanel.tsx:124 and
components/ManuscriptResearchSplit.tsx:92 both render PDF/asset previews via
<iframe src={blobUrl}>, where blobUrl comes from URL.createObjectURL() on an
IndexedDB-backed asset (no same-origin static file exists to point at instead).
- Add
'wasm-unsafe-eval'toscript-srcon all 5 surfaces β not the broader'unsafe-eval'keyword, which remains strictly forbidden.'wasm-unsafe-eval'only lifts the restriction onWebAssembly.compile/instantiate(Streaming). - Add
frame-src 'self' blob:to all 5 surfaces. - Move the inline
aurora-disabledscript out ofindex.htmland intoindex.tsxas a same-origin module statement (runs as the first executable line after imports, before the SPA redirect handler and beforeReactDOM.createRoot) β no'unsafe-inline', no hash to maintain. - Build a 3-layer test architecture so this class of defect cannot silently recur:
- Layer A (consistency) β
tests/unit/csp.test.ts,tests/unit/deploymentHeaders.test.ts(pre-existing, unchanged in intent). - Layer B (correctness, new) β
tests/unit/cspCorrectness.test.ts: asserts, across all 5 surfaces, thatscript-srccontains'wasm-unsafe-eval'and neither'unsafe-eval'nor'unsafe-inline'; thatframe-src/child-srcallowsblob:; regression guards onworker-src/img-srcblob:,object-src 'none',base-uri 'self',frame-ancestors 'none'; and β the assertion that would have caught the 2026-05-27 defect on day one β that every inline<script>inindex.htmlwithout asrcattribute has a matching'sha256-β¦'entry inscript-src, or that none exist. - Layer C (runtime, hardened) β
scripts/smoke-prod-build.mjsnow additionally: registers asecuritypolicyviolationDOM listener viapage.addInitScript; listens onpage.on('console')for actual block/refusal phrasing (excluding the permanent, harmlessframe-ancestors-ignored-in-<meta>notice); and runs a realWebAssembly.instantiate(new Uint8Array([0,97,115,109,1,0,0,0]))probe in headless Chromium, failing the gate on any unexpected violation or a blocked WASM probe.
- Layer A (consistency) β
Layer C surfaced a fourth, distinct securitypolicyviolation after the fix: zod v4's own internal
JIT-availability probe (new Function("") inside a try/catch, used to decide between
compiled-validator fast paths and a graceful jitless fallback β traced to the exact blocked column
in the built zod-*.js chunk during this audit) is CSP-classified under 'unsafe-eval', which this
app deliberately does not grant. zod already handles the failure gracefully (falls back to jitless
validation; nothing breaks). Enabling 'unsafe-eval' to silence this is forbidden by this ADR's own
decision. scripts/smoke-prod-build.mjs therefore excludes this one specific, precisely-matched
case (blockedURI === 'eval' and sourceFile matches /assets/zod-*.js) from the "0
violations" bar, logging it separately as a known-benign, third-party, self-handled degradation β
not silently ignored, and not a hand-wave: any other eval-classified violation still fails the
gate.
'unsafe-eval'β strictly broader than needed; grants arbitraryeval/Functionexecution, not just WASM compilation. Forbidden outright.- Drop WebAssembly entirely β guts the product's core advertised differentiator (local-first AI inference). Not viable.
- Hash the inline script instead of moving it β viable fallback if moving it into
index.tsxhad introduced a visible Aurora-effect flash on low-end-CPU simulation (deferred module execution vs. inline parser-time execution). Verified in this sprint: moving it produced no observable regression, so the simpler, hash-free option was kept. A hand-maintained hash would silently drift from the actual script content on any future edit β noted here in case a future change needs to revisit this tradeoff. - Silence the zod JIT-probe finding entirely (don't log it) β rejected; logging it (excluded
from the pass/fail count, but visible in output) keeps the exception auditable rather than a silent
carve-out that could mask a real future
evalviolation hiding behind the same exclusion filter if the filter were made too broad. The filter is deliberately narrow (exactblockedURI+ exact chunk-name-prefix match).
- Positive: the advertised local-inference stack (WebLLM, ONNX Runtime Web, Transformers.js,
DuckDB-WASM, Whisper-STT, Kokoro-TTS) can now actually run in every deployed Chromium environment.
Binder-PDF-preview and ManuscriptResearchSplit iframes work. A commit that reintroduces this class
of defect (missing
'wasm-unsafe-eval', an unhashed inline script, or a missingframe-src) now fails in 3 independent places before it can reach production. - Negative (accepted):
'wasm-unsafe-eval'is a real, if narrow, expansion of what script the page's origin can execute β any script that can reachscript-src(i.e., any first-party code, or a successful same-origin injection) can now compile and run arbitrary WebAssembly. This is inherent to shipping a WASM-based local-inference feature at all; no CSP directive can scope WASM permission to only "trusted" first-party code. The plugin sandbox is unaffected β see the JS-level guard analysis below.
No. workers/plugin.worker.ts sets self.WebAssembly = undefined before executing untrusted
plugin code (workers/plugin.worker.ts:128, snapshotting the original at :103) and restores it
afterward on both the success and error paths (:164, with 'WebAssembly' in the guard-check
list at :203). This is a JS-level guard, entirely independent of CSP β it works by removing the
global binding a plugin's sandboxed code could call, not by relying on the browser's CSP enforcement.
'wasm-unsafe-eval' changes what the page's own first-party code may do; it does not restore
self.WebAssembly inside a plugin's execution context, where the JS-level guard already denies it
regardless of CSP. The adversarial tests in tests/unit/workers/plugin.worker.test.ts (WebAssembly
denial, Function/AsyncFunction/GeneratorFunction/AsyncGeneratorFunction constructor-escape
blocks, guard-restoration on both success and error paths) were re-run after this change and remain
green, unmodified.
index.html,vercel.json,public/_headers,nginx.conf,src-tauri/tauri.conf.json(the 5 CSP surfaces)index.tsx(movedaurora-disabledscript)tests/unit/cspCorrectness.test.ts(Layer B, new)scripts/smoke-prod-build.mjs(Layer C, hardened)tests/unit/workers/plugin.worker.test.ts,workers/plugin.worker.ts(plugin-sandbox non-impact)docs/DEPLOYMENT.mdΒ§ Why'wasm-unsafe-eval',docs/SECURITY-THREAT-MODEL.mdΒ§ CSP script-src- Origin commit
faad8f0(2026-05-27); auditPROMPT-WSS-v1.24.x, findings F-01β¦F-04 (2026-07-29) - [[0004-csp-connect-src-byok-tradeoff]]