From 98dec3e80e10236c6dbd5e697ed914a3909a9ee7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 11:16:41 +0200 Subject: [PATCH 1/2] fix(wasm): load without script-src 'unsafe-eval' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit embind generates its invoker functions at run time with `new Function`, which CSP treats as eval, so `Odr.load()` threw an `EvalError` under any policy that did not grant `'unsafe-eval'` — the one thing a page whose selling point is that documents are processed locally would rather not grant. The glue contains no literal `eval(`, which makes it easy to misdiagnose. `-sDYNAMIC_EXECUTION=0` picks embind's closure-based path instead. What it costs is `emscripten_run_script`, `dlopen`, and `ccall`/`cwrap` for anything not exported in advance — none of which the bindings use. `script-src 'self' 'wasm-unsafe-eval'` is now enough. Closes #709 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDs5aK3ZGSZsEvqUUwBBXU --- CHANGELOG.md | 5 +++++ wasm/CMakeLists.txt | 8 ++++++++ wasm/README.md | 3 +++ wasm/tests/csp.test.mjs | 38 ++++++++++++++++++++++++++++++++++++++ wasm/tests/helper.mjs | 2 +- 5 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 wasm/tests/csp.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8224c2dde..f92e12c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ The release run heads these entries with the version and opens a fresh `pop()` and `depth()` do what they say instead of nothing. - A directory a document or an archive only implies is one: `exists()` and `is_directory()` answer for `/` and for a path that files sit under. +- `Odr.load()` works under a Content-Security-Policy without `'unsafe-eval'`: + the wasm module is linked with `-sDYNAMIC_EXECUTION=0`, so embind builds its + invokers without `new Function`. `script-src 'self' 'wasm-unsafe-eval'` is + now enough — which is the policy a page processing documents locally would + want to be able to ship. ## v6.9.0 - 2026-08-18 diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 676f3a8a2..da0da8f0f 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -71,6 +71,14 @@ target_link_options(odr_wasm PRIVATE # failure at 64 KB does not look like a stack overflow. -sSTACK_SIZE=8388608 -sFILESYSTEM=1 + # embind builds its invokers with `new Function`, which a + # Content-Security-Policy treats as eval - so loading the module needed + # `script-src 'unsafe-eval'`, the one thing a page embedding a local + # document renderer would rather not grant. This picks embind's + # closure-based path instead, leaving `'wasm-unsafe-eval'` enough. + # Nothing here calls `emscripten_run_script`, `dlopen`, `ccall` or + # `cwrap`, which is what it costs. + -sDYNAMIC_EXECUTION=0 ) # The hand-written half of the package sits beside the generated glue, so the diff --git a/wasm/README.md b/wasm/README.md index 1b04fc021..f58711236 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -76,6 +76,9 @@ where `Symbol.dispose` is supported. ## Hosting - Serve `.wasm` as `application/wasm`, or the browser cannot stream-compile it. +- `script-src 'self' 'wasm-unsafe-eval'` is enough to load the module. It is + linked with `-sDYNAMIC_EXECUTION=0`, so embind builds its invokers without + `new Function` and no `'unsafe-eval'` is needed. - **Enable brotli.** It takes the module from 2.9 M to about 830 K — worth more than every code-size flag put together. Hosts that only gzip land at ~1.2 M. - No COOP/COEP headers needed. The build is deliberately single-threaded so diff --git a/wasm/tests/csp.test.mjs b/wasm/tests/csp.test.mjs new file mode 100644 index 000000000..17570d820 --- /dev/null +++ b/wasm/tests/csp.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { dist } from './helper.mjs'; + +// A page embedding a local document renderer wants `script-src 'self' +// 'wasm-unsafe-eval'`, which allows `WebAssembly.instantiate` but not +// `new Function`. embind builds its invokers with the latter unless the module +// is linked with `-sDYNAMIC_EXECUTION=0`, so `Odr.load()` threw an `EvalError` +// under any such policy. +describe('content security policy', () => { + it('loads where dynamic code construction is blocked', () => { + // A child process, because the stand-in below replaces a global the test + // runner itself uses. + const script = ` + globalThis.Function = new Proxy(Function, { + construct() { throw new EvalError('blocked by the stand-in CSP'); }, + apply() { throw new EvalError('blocked by the stand-in CSP'); }, + }); + const { Odr } = await import(${JSON.stringify(join(dist, 'index.js'))}); + await Odr.load(); + `; + + execFileSync(process.execPath, ['--input-type=module', '-e', script], { + stdio: 'pipe', + }); + }); + + it('ships glue that builds no code at run time', () => { + const glue = readFileSync(join(dist, 'odr-core.mjs'), 'utf8'); + + assert.doesNotMatch(glue, /new Function\b/); + assert.doesNotMatch(glue, /[^\w.$]eval\(/); + }); +}); diff --git a/wasm/tests/helper.mjs b/wasm/tests/helper.mjs index 1b560fd64..9528fdedc 100644 --- a/wasm/tests/helper.mjs +++ b/wasm/tests/helper.mjs @@ -11,7 +11,7 @@ const here = dirname(fileURLToPath(import.meta.url)); // `ODR_WASM_DIST` is set by ctest; the fallback is where a by-hand cmake build // puts it. -const dist = process.env.ODR_WASM_DIST ?? join(here, '..', '..', 'dist'); +export const dist = process.env.ODR_WASM_DIST ?? join(here, '..', '..', 'dist'); // A static `export ... from` needs a literal specifier, and the package's // location is only known at run time, so the module is loaded once up front. From 99b6eb1cbd31428d5de33f2370d9eeac2a101e1e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 14:25:54 +0200 Subject: [PATCH 2/2] docs(wasm): trim the DYNAMIC_EXECUTION prose Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 3 +-- wasm/CMakeLists.txt | 11 ++++------- wasm/tests/csp.test.mjs | 8 +++----- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f92e12c3e..4cca131c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,7 @@ The release run heads these entries with the version and opens a fresh - `Odr.load()` works under a Content-Security-Policy without `'unsafe-eval'`: the wasm module is linked with `-sDYNAMIC_EXECUTION=0`, so embind builds its invokers without `new Function`. `script-src 'self' 'wasm-unsafe-eval'` is - now enough — which is the policy a page processing documents locally would - want to be able to ship. + now enough. ## v6.9.0 - 2026-08-18 diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index da0da8f0f..103e914c0 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -71,13 +71,10 @@ target_link_options(odr_wasm PRIVATE # failure at 64 KB does not look like a stack overflow. -sSTACK_SIZE=8388608 -sFILESYSTEM=1 - # embind builds its invokers with `new Function`, which a - # Content-Security-Policy treats as eval - so loading the module needed - # `script-src 'unsafe-eval'`, the one thing a page embedding a local - # document renderer would rather not grant. This picks embind's - # closure-based path instead, leaving `'wasm-unsafe-eval'` enough. - # Nothing here calls `emscripten_run_script`, `dlopen`, `ccall` or - # `cwrap`, which is what it costs. + # embind otherwise builds its invokers with `new Function`, which CSP + # treats as eval, so loading needed `script-src 'unsafe-eval'`. The + # closure-based path costs `emscripten_run_script`, `dlopen`, `ccall` + # and `cwrap`, none of which this uses. -sDYNAMIC_EXECUTION=0 ) diff --git a/wasm/tests/csp.test.mjs b/wasm/tests/csp.test.mjs index 17570d820..d68361ef4 100644 --- a/wasm/tests/csp.test.mjs +++ b/wasm/tests/csp.test.mjs @@ -6,11 +6,9 @@ import { describe, it } from 'node:test'; import { dist } from './helper.mjs'; -// A page embedding a local document renderer wants `script-src 'self' -// 'wasm-unsafe-eval'`, which allows `WebAssembly.instantiate` but not -// `new Function`. embind builds its invokers with the latter unless the module -// is linked with `-sDYNAMIC_EXECUTION=0`, so `Odr.load()` threw an `EvalError` -// under any such policy. +// `wasm-unsafe-eval` allows `WebAssembly.instantiate` but not `new Function`, +// which embind uses for its invokers unless linked with +// `-sDYNAMIC_EXECUTION=0`. describe('content security policy', () => { it('loads where dynamic code construction is blocked', () => { // A child process, because the stand-in below replaces a global the test