Skip to content

fix(pow): support strict CSP without unsafe-eval - #32

Open
edgepillar wants to merge 2 commits into
digitalSloth:mainfrom
edgepillar:codex/csp-safe-pow
Open

edgepillar wants to merge 2 commits into
digitalSloth:mainfrom
edgepillar:codex/csp-safe-pow

Conversation

@edgepillar

@edgepillar edgepillar commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • rebuild the PoW glue with dynamic JavaScript execution disabled and Embind AOT enabled
  • replace the Node new Function(...import...) shim and browser inline module script with native dynamic imports
  • pin the PoW source and Emscripten version for reproducible assets
  • add regression coverage and document the exact browser CSP requirements

Scope

This is limited to the existing PoW loader, generated JS/WASM assets, build script, tests, and documentation. It does not change the PoW algorithm, wire format, or public transaction API.

The resulting browser policy for the built-in worker, when pow.js and
pow.wasm are served from the same origin, can use:

script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;
connect-src 'self';

The generated PoW loader no longer requires 'unsafe-eval'. The narrower
'wasm-unsafe-eval' permission is still required for WebAssembly compilation
in Chromium.

The native browser import() path intentionally removes the previous
SDK-managed 10-second polling timeout; loading now follows the platform's
module and fetch promise behavior.

Reproducibility

  • PoW source: zenon-network/znn-pow-links-cpp@9c63abdcd4e6bd642a81476cbff2f5190efabe95
  • Emscripten: 6.0.8
  • flags: DYNAMIC_EXECUTION=0, EMBIND_AOT=1
  • the build now fails if generated pow.js contains eval() or new Function()

Validation

  • npm run build:wasm
  • npm test — 649 passing locally; GitHub Actions passed on Node 20 and 22
  • npm run lint — 0 errors
  • npm run cover — passed in GitHub Actions on Node 20
  • npm run build
  • npm pack --dry-run --ignore-scripts
  • compiled Node PoW initialization and nonce generation
  • Chromium runtime checks for direct and SDK-integrated main-thread and worker paths, with zero CSP violations
  • negative Chromium control without 'wasm-unsafe-eval', which failed only at WebAssembly compilation as expected

@digitalSloth

Copy link
Copy Markdown
Owner

@edgepillar Nice fix for the core problem — replacing the embind new Function invoker and the inline <script> interpolation with a native import() is the right shape, and I rebuilt the wasm/js pair locally against the pinned Emscripten version and upstream commit and got a byte-for-byte match with what's committed here, so no concerns on artifact provenance.

Two things I'd want addressed before merge:

  1. The documented CSP (README.md, docs/build-wasm.md) is missing connect-src 'self'. The browser glue loads pow.wasm via fetch(), which connect-src governs, not script-src — under the policy exactly as documented, the wasm fetch still gets blocked.
  2. test/pow/powCsp.spec.ts mostly asserts source/script text rather than behavior (e.g. checking literal strings inside build-wasm.sh, which the test never runs). Worth keeping the "no eval/new Function in the shipped glue" check and replacing the rest with a nonce-validity assertion so the test actually catches a bad or swapped artifact.

Minor nits, not blocking:

  • The regenerated glue drops the wasmBinary handling that src/pow/pow.ts still relies on — readFileSync'd bytes are read but never passed through; harmless today since the files sit side by side, but the code and its comment are now misleading.
  • scripts/build-wasm.sh's Emscripten version check (*"6.0.8"*) is an unanchored substring match against the whole emcc --version banner.
  • The #znn-pow-module hash suffix on the module URL (src/pow/pow.ts:50-51) looks unnecessary — couldn't reproduce a loader that needs it.
  • The browser import() path lost the old 10s load timeout; worth a one-line mention in the PR description if intentional.

Once the CSP docs and the test file are tightened up this looks good to me.

@digitalSloth
digitalSloth marked this pull request as ready for review September 2, 2026 21:38
@edgepillar

Copy link
Copy Markdown
Author

Thanks for the detailed review and for independently reproducing the generated artifacts.

I addressed the two requested changes in beff10e:

  • added connect-src 'self' to the same-origin CSP guidance in both README.md and docs/build-wasm.md;
  • replaced the loader/build-script string assertions with real PoW generation and an independent SHA3-256 nonce-validity check using the canonical little-endian threshold comparison.

I also updated the PR description with the complete CSP example and documented the intentional removal of the previous SDK-managed 10-second polling timeout. The fresh Node 20, Node 22, and coverage checks are green.

For the remaining non-blocking notes, I left the unused wasmBinary path, Emscripten version-match tightening, and module-URL fragment unchanged to keep this follow-up limited to the requested blockers. I’m happy to address any of those here if you would prefer.

@digitalSloth

Copy link
Copy Markdown
Owner

@edgepillar Thanks for the fix — both blockers from the first pass are addressed. connect-src 'self' is correctly scoped, and the rewritten powCsp.spec.ts is a real improvement: it executes the actual WASM and verifies the nonce against the real PoW rule instead of asserting source text.

One thing still needs work before merge: the test uses a single hardcoded input (hash = "00".repeat(32), difficulty = 1024), so it's precomputable — I can substitute the entire glue with a 14-line stub that hardcodes the one valid answer and it passes the full suite — and even a module returning arbitrary nonces passes 1 run in 1024 by chance. Suggested fix, about 2 lines: randomize the hash per run and raise difficulty to something like 75000 (measured ~123ms, well inside the timeout). That closes both gaps. A SHA-256 artifact pin or a CI rebuild-and-compare job would add identity pinning on top, but isn't required to land this.

Minor, non-blocking:

  • docs/build-wasm.md's CSP directive list is missing worker-src 'self' blob:, which the README includes — worth keeping the two in sync (or linking one to the other).
  • The README's CSP note is framed only around usePowWorker(); the main-thread loader needs the same script-src/connect-src and isn't mentioned.
  • docs/build-wasm.md's verification step 4 ("run the test suite and compare behavior") is exactly the check that the precomputable-input issue above shows isn't sufficient — a digest comparison would be more accurate.
  • expect(value >= threshold).to.equal(true) loses its failure message; expect(value).to.be.at.least(threshold) is a straightforward improvement.
  • Dropping the old test removed the only (text-based) guard on the webpackIgnore/@vite-ignore annotations — not urgent, just flagging so it's a conscious tradeoff.

The previously-deferred minors (unanchored Emscripten version match, dropped Module.wasmBinary handling, the #znn-pow-module hash, redundant build-script guards, lost browser timeout) are still fine to leave for a follow-up as you proposed — though I'd still ask for the Module.wasmBinary one at some point since the code comment describing it is now inaccurate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants