diff --git a/CHANGELOG.md b/CHANGELOG.md index 8224c2dd..4cca131c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ 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. ## v6.9.0 - 2026-08-18 diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 676f3a8a..103e914c 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -71,6 +71,11 @@ target_link_options(odr_wasm PRIVATE # failure at 64 KB does not look like a stack overflow. -sSTACK_SIZE=8388608 -sFILESYSTEM=1 + # 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 ) # 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 1b04fc02..f5871123 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 00000000..d68361ef --- /dev/null +++ b/wasm/tests/csp.test.mjs @@ -0,0 +1,36 @@ +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'; + +// `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 + // 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 1b560fd6..9528fded 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.