diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d843674..b57de41 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -118,6 +118,7 @@ jobs: "babel.config.json" "lerna.json" "tools/ci/" + "tools/csp/" "tools/dist-size/" "tools/browser-smoke/" "tools/fixture-verification/" diff --git a/packages/charls/CMakeLists.txt b/packages/charls/CMakeLists.txt index ed6a741..c7844b0 100644 --- a/packages/charls/CMakeLists.txt +++ b/packages/charls/CMakeLists.txt @@ -36,6 +36,11 @@ add_subdirectory(extern/charls EXCLUDE_FROM_ALL) # add the js wrapper if(EMSCRIPTEN) + # Generate Embind adapters at build time so consumers do not need CSP unsafe-eval. + add_link_options( + "-sDYNAMIC_EXECUTION=0" + "-sEMBIND_AOT=1" + ) add_subdirectory(src) endif() diff --git a/packages/charls/build.sh b/packages/charls/build.sh index 7acb203..2c11195 100644 --- a/packages/charls/build.sh +++ b/packages/charls/build.sh @@ -14,4 +14,7 @@ cp ./build/src/charlswasm_decode.js ./dist cp ./build/src/charlswasm_decode.wasm ./dist cp ./build/src/charlsjs_decode.js ./dist cp ./build/src/charlsjs_decode.js.mem ./dist -(npm run test:benchmark) +test_status=0 +(npm run test:benchmark) || test_status=$? +node ../../tools/csp/check-generated-js.js ./dist || exit $? +exit "${test_status}" diff --git a/packages/libjpeg-turbo-12bit/CMakeLists.txt b/packages/libjpeg-turbo-12bit/CMakeLists.txt index 00807fd..a1666b9 100644 --- a/packages/libjpeg-turbo-12bit/CMakeLists.txt +++ b/packages/libjpeg-turbo-12bit/CMakeLists.txt @@ -39,6 +39,11 @@ add_subdirectory(extern/libjpeg-turbo EXCLUDE_FROM_ALL) # add the js wrapper if(EMSCRIPTEN) + # Generate Embind adapters at build time so consumers do not need CSP unsafe-eval. + add_link_options( + "-sDYNAMIC_EXECUTION=0" + "-sEMBIND_AOT=1" + ) add_subdirectory(src) endif() diff --git a/packages/libjpeg-turbo-12bit/build.sh b/packages/libjpeg-turbo-12bit/build.sh index 8593234..46f47cc 100644 --- a/packages/libjpeg-turbo-12bit/build.sh +++ b/packages/libjpeg-turbo-12bit/build.sh @@ -22,5 +22,6 @@ echo "~~~ BUILD:" (cd build && dir) echo "~~~ DIST:" (cd dist && dir) +node ../../tools/csp/check-generated-js.js ./dist # echo "~~~ TEST:" # (cd test/node; npm run test) diff --git a/packages/libjpeg-turbo-8bit/CMakeLists.txt b/packages/libjpeg-turbo-8bit/CMakeLists.txt index 00807fd..a1666b9 100644 --- a/packages/libjpeg-turbo-8bit/CMakeLists.txt +++ b/packages/libjpeg-turbo-8bit/CMakeLists.txt @@ -39,6 +39,11 @@ add_subdirectory(extern/libjpeg-turbo EXCLUDE_FROM_ALL) # add the js wrapper if(EMSCRIPTEN) + # Generate Embind adapters at build time so consumers do not need CSP unsafe-eval. + add_link_options( + "-sDYNAMIC_EXECUTION=0" + "-sEMBIND_AOT=1" + ) add_subdirectory(src) endif() diff --git a/packages/libjpeg-turbo-8bit/build.sh b/packages/libjpeg-turbo-8bit/build.sh index 8741403..185b1ce 100644 --- a/packages/libjpeg-turbo-8bit/build.sh +++ b/packages/libjpeg-turbo-8bit/build.sh @@ -30,4 +30,7 @@ echo "~~~ BUILD:" echo "~~~ DIST:" (cd dist && dir) echo "~~~ TEST:" -(cd test/node; npm run test) +test_status=0 +(cd test/node; npm run test) || test_status=$? +node ../../tools/csp/check-generated-js.js ./dist || exit $? +exit "${test_status}" diff --git a/packages/openjpeg/CMakeLists.txt b/packages/openjpeg/CMakeLists.txt index 0fdd002..49986f2 100644 --- a/packages/openjpeg/CMakeLists.txt +++ b/packages/openjpeg/CMakeLists.txt @@ -40,6 +40,11 @@ add_subdirectory(extern/openjpeg EXCLUDE_FROM_ALL) # add the js wrapper if(EMSCRIPTEN) + # Generate Embind adapters at build time so consumers do not need CSP unsafe-eval. + add_link_options( + "-sDYNAMIC_EXECUTION=0" + "-sEMBIND_AOT=1" + ) add_subdirectory(src) endif() diff --git a/packages/openjpeg/build.sh b/packages/openjpeg/build.sh index a16de3c..40d10ae 100644 --- a/packages/openjpeg/build.sh +++ b/packages/openjpeg/build.sh @@ -33,4 +33,7 @@ echo "~~~ BUILD:" echo "~~~ DIST:" (cd dist && dir) echo "~~~ TEST:" -(cd test/node; npm run test) +test_status=0 +(cd test/node; npm run test) || test_status=$? +node ../../tools/csp/check-generated-js.js ./dist || exit $? +exit "${test_status}" diff --git a/packages/openjphjs/CMakeLists.txt b/packages/openjphjs/CMakeLists.txt index b3a1b03..d5ec391 100644 --- a/packages/openjphjs/CMakeLists.txt +++ b/packages/openjphjs/CMakeLists.txt @@ -27,6 +27,11 @@ add_subdirectory(extern/openjph EXCLUDE_FROM_ALL) # add the js wrapper if(EMSCRIPTEN) + # Generate Embind adapters at build time so consumers do not need CSP unsafe-eval. + add_link_options( + "-sDYNAMIC_EXECUTION=0" + "-sEMBIND_AOT=1" + ) add_subdirectory(src) endif() diff --git a/packages/openjphjs/build.sh b/packages/openjphjs/build.sh index 52d23ab..0e7121e 100755 --- a/packages/openjphjs/build.sh +++ b/packages/openjphjs/build.sh @@ -1,4 +1,6 @@ #!/bin/sh +set -e + mkdir -p build mkdir -p dist (cd build && CXXFLAGS=-msimd128 emcmake cmake -DCMAKE_BUILD_TYPE=Debug ..) @@ -8,3 +10,4 @@ cp ./build/src/openjphjs.js ./dist cp ./build/src/openjphjs.wasm ./dist # disable tests for now since CI doesn't like to run with SIMD # (cd test/node; npm run test) +node ../../tools/csp/check-generated-js.js ./dist diff --git a/tools/browser-smoke/run.js b/tools/browser-smoke/run.js index 1a15175..98a97e8 100644 --- a/tools/browser-smoke/run.js +++ b/tools/browser-smoke/run.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // Browser smoke-decode: loads every wasm/asm.js build variant of every -// codec in headless Chromium, decodes its reference fixture IN THE PAGE, -// and compares the SHA-256 of the decoded pixels against the RAW reference. +// codec in headless Chromium. Active decoders process a reference fixture +// IN THE PAGE and compare its SHA-256 against the RAW reference. // // This is the coverage node tests cannot give: emscripten glue differences // that only manifest in browsers (wasm URL resolution/locateFile, fetch vs @@ -21,17 +21,25 @@ const { chromium } = require("playwright-core"); const repoRoot = path.resolve(__dirname, "../.."); // [package, dist module, decoder class, encoded fixture, reference raw] -// Every entry decodes CT1/jpeg400 and must hash-match its committed RAW. +// Active decoders hash-match CT1/jpeg400; disabled decoders initialize only. const VARIANTS = [ ["charls", "charlsjs.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], + ["charls", "charlsjs_decode.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], ["charls", "charlswasm.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], ["charls", "charlswasm_decode.js", "JpegLSDecoder", "charls/test/fixtures/CT1.JLS", "charls/test/fixtures/CT1.RAW"], ["openjpeg", "openjpegjs.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], + ["openjpeg", "openjpegjs_decode.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], ["openjpeg", "openjpegwasm.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], ["openjpeg", "openjpegwasm_decode.js", "J2KDecoder", "openjpeg/test/fixtures/j2k/CT1.j2k", "openjpeg/test/fixtures/raw/CT1.RAW"], ["openjphjs", "openjphjs.js", "HTJ2KDecoder", "openjphjs/test/fixtures/j2c/CT1.j2c", "openjphjs/test/fixtures/raw/CT1.RAW"], ["libjpeg-turbo-8bit", "libjpegturbojs.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], + ["libjpeg-turbo-8bit", "libjpegturbojs_decode.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], ["libjpeg-turbo-8bit", "libjpegturbowasm.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], + ["libjpeg-turbo-8bit", "libjpegturbowasm_decode.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], + // The 12-bit decode path is disabled, but module initialization still + // exercises its generated JavaScript and WebAssembly under the CSP. + ["libjpeg-turbo-12bit", "libjpegturbo12js.js"], + ["libjpeg-turbo-12bit", "libjpegturbo12wasm.js"], ]; const MIME = { @@ -51,7 +59,11 @@ function startServer() { res.end("not found"); return; } - res.writeHead(200, { "Content-Type": MIME[path.extname(filePath)] ?? "application/octet-stream" }); + res.writeHead(200, { + "Content-Type": MIME[path.extname(filePath)] ?? "application/octet-stream", + // Permit WebAssembly compilation without permitting eval() or Function(). + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + }); fs.createReadStream(filePath).pipe(res); }); return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); @@ -73,6 +85,8 @@ const PAGE_FN = async ({ distUrl, decoderClass, fixtureUrl }) => { const codec = await factory(); delete globalThis.Module; + if (!decoderClass) return null; + const fixture = new Uint8Array(await (await fetch(fixtureUrl)).arrayBuffer()); const decoder = new codec[decoderClass](); decoder.getEncodedBuffer(fixture.length).set(fixture); @@ -100,7 +114,9 @@ const PAGE_FN = async ({ distUrl, decoderClass, fixtureUrl }) => { if (process.env.CI) results.push([`${pkg}/${dist}`, "FAIL: dist missing in CI"]); continue; } - const expected = crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, "packages", raw))).digest("hex"); + const expected = raw + ? crypto.createHash("sha256").update(fs.readFileSync(path.join(repoRoot, "packages", raw))).digest("hex") + : null; const page = await browser.newPage(); const pageErrors = []; page.on("pageerror", (e) => pageErrors.push(e.message)); @@ -111,7 +127,8 @@ const PAGE_FN = async ({ distUrl, decoderClass, fixtureUrl }) => { decoderClass, fixtureUrl: `http://127.0.0.1:${port}/packages/${fixture}`, }); - results.push([`${pkg}/${dist}`, actual === expected ? "PASS" : `FAIL: hash mismatch (${actual.slice(0, 12)}… != ${expected.slice(0, 12)}…)`]); + const success = decoderClass ? "PASS" : "PASS (module initialized)"; + results.push([`${pkg}/${dist}`, actual === expected ? success : `FAIL: hash mismatch (${actual.slice(0, 12)}… != ${expected.slice(0, 12)}…)`]); } catch (e) { results.push([`${pkg}/${dist}`, `FAIL: ${e.message}${pageErrors.length ? " | page: " + pageErrors.join("; ") : ""}`]); } finally { @@ -125,6 +142,6 @@ const PAGE_FN = async ({ distUrl, decoderClass, fixtureUrl }) => { const width = Math.max(...results.map(([n]) => n.length)); for (const [name, r] of results) console.log(name.padEnd(width + 2) + r); const failures = results.filter(([, r]) => r.startsWith("FAIL")).length; - console.log(`\n${failures === 0 ? "PASS" : "FAIL"}: ${failures} browser decode failure(s)`); + console.log(`\n${failures === 0 ? "PASS" : "FAIL"}: ${failures} browser smoke failure(s)`); process.exit(failures ? 1 : 0); })(); diff --git a/tools/csp/check-generated-js.js b/tools/csp/check-generated-js.js new file mode 100644 index 0000000..a3d768e --- /dev/null +++ b/tools/csp/check-generated-js.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const distDirectory = path.resolve(process.argv[2] ?? ""); + +if (!process.argv[2] || !fs.existsSync(distDirectory)) { + console.error("Usage: node tools/csp/check-generated-js.js "); + process.exit(1); +} + +const javascriptFiles = fs + .readdirSync(distDirectory) + .filter((fileName) => fileName.endsWith(".js")) + .sort(); + +if (javascriptFiles.length === 0) { + console.error(`No generated JavaScript found in ${distDirectory}`); + process.exit(1); +} + +const forbiddenDynamicCode = [ + ["eval()", /\beval\s*\(/], + ["Function constructor", /\b(?:new\s+)?Function\s*\(/], + ["Emscripten Function constructor", /\bnewFunc\s*\(\s*Function\s*,/], +]; + +const violations = []; + +for (const fileName of javascriptFiles) { + const source = fs.readFileSync(path.join(distDirectory, fileName), "utf8"); + + for (const [description, pattern] of forbiddenDynamicCode) { + if (pattern.test(source)) { + violations.push(`${fileName}: ${description}`); + } + } +} + +if (violations.length > 0) { + console.error("Generated codec JavaScript contains CSP-unsafe dynamic code:"); + violations.forEach((violation) => console.error(`- ${violation}`)); + process.exit(1); +} + +console.log(`CSP-safe generated JavaScript: ${javascriptFiles.length} file(s) checked`); diff --git a/tools/csp/check-generated-js.test.js b/tools/csp/check-generated-js.test.js new file mode 100644 index 0000000..e968df3 --- /dev/null +++ b/tools/csp/check-generated-js.test.js @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from "vitest" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +const checkerPath = fileURLToPath( + new URL("./check-generated-js.js", import.meta.url) +) +const temporaryDirectories = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function runChecker(source) { + const directory = mkdtempSync(join(tmpdir(), "codec-csp-check-")) + temporaryDirectories.push(directory) + writeFileSync(join(directory, "generated.js"), source) + + return spawnSync(process.execPath, [checkerPath, directory], { + encoding: "utf8", + }) +} + +describe("generated JavaScript CSP checker", () => { + it.each([ + ["eval()", 'eval("dynamic code")', "eval()"], + ["Function()", 'Function("return 1")', "Function constructor"], + ["new Function()", 'new Function("return 1")', "Function constructor"], + [ + "Emscripten Function adapter", + 'newFunc(Function, "arg", "return arg")', + "Emscripten Function constructor", + ], + ])("rejects %s", (_name, source, expectedViolation) => { + const result = runChecker(source) + + expect(result.status).toBe(1) + expect(result.stderr).toContain(expectedViolation) + }) + + it("accepts generated JavaScript without dynamic code", () => { + const result = runChecker("const add = (left, right) => left + right") + + expect(result.status).toBe(0) + expect(result.stdout).toContain("CSP-safe generated JavaScript: 1 file(s) checked") + }) +}) diff --git a/tools/csp/vitest.config.mjs b/tools/csp/vitest.config.mjs new file mode 100644 index 0000000..a84fe81 --- /dev/null +++ b/tools/csp/vitest.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + name: "csp", + include: ["*.test.js"], + }, +}) diff --git a/tools/dist-size/baseline.json b/tools/dist-size/baseline.json index b9fa184..b109eee 100644 --- a/tools/dist-size/baseline.json +++ b/tools/dist-size/baseline.json @@ -7,24 +7,24 @@ }, "charls": { "charlsjs.js": { - "raw": 537029, - "gzip": 108457 + "raw": 537142, + "gzip": 108201 }, "charlsjs_decode.js": { - "raw": 340455, - "gzip": 77502 + "raw": 340214, + "gzip": 77220 }, "charlswasm.js": { - "raw": 54139, - "gzip": 14501 + "raw": 54252, + "gzip": 14248 }, "charlswasm.wasm": { "raw": 247884, "gzip": 66579 }, "charlswasm_decode.js": { - "raw": 54146, - "gzip": 14506 + "raw": 53905, + "gzip": 14232 }, "charlswasm_decode.wasm": { "raw": 145306, @@ -33,42 +33,42 @@ }, "libjpeg-turbo-12bit": { "libjpegturbo12js.js": { - "raw": 2554703, - "gzip": 267545 + "raw": 2461618, + "gzip": 259496 }, "libjpegturbo12wasm.js": { - "raw": 113584, - "gzip": 29160 + "raw": 112153, + "gzip": 28570 }, "libjpegturbo12wasm.wasm": { - "raw": 2238843, - "gzip": 760829 + "raw": 1954242, + "gzip": 688878 } }, "libjpeg-turbo-8bit": { "libjpegturbojs.js": { - "raw": 838040, - "gzip": 160427 + "raw": 837711, + "gzip": 160006 }, "libjpegturbojs_decode.js": { - "raw": 418778, - "gzip": 118564 + "raw": 417854, + "gzip": 117868 }, "libjpegturbowasm.js": { - "raw": 57327, - "gzip": 15530 + "raw": 57440, + "gzip": 15274 }, "libjpegturbowasm.wasm": { - "raw": 448955, - "gzip": 96494 + "raw": 448728, + "gzip": 96385 }, "libjpegturbowasm_decode.js": { - "raw": 56836, - "gzip": 15491 + "raw": 56350, + "gzip": 15191 }, "libjpegturbowasm_decode.wasm": { - "raw": 180512, - "gzip": 70074 + "raw": 180287, + "gzip": 69946 } }, "little-endian": { @@ -79,38 +79,38 @@ }, "openjpeg": { "openjpegjs.js": { - "raw": 754525, - "gzip": 199851 + "raw": 753931, + "gzip": 199072 }, "openjpegjs_decode.js": { - "raw": 541739, - "gzip": 139864 + "raw": 540905, + "gzip": 139321 }, "openjpegwasm.js": { - "raw": 56588, - "gzip": 15346 + "raw": 57685, + "gzip": 15161 }, "openjpegwasm.wasm": { - "raw": 368511, - "gzip": 127471 + "raw": 367561, + "gzip": 127108 }, "openjpegwasm_decode.js": { - "raw": 55920, - "gzip": 15236 + "raw": 55759, + "gzip": 14970 }, "openjpegwasm_decode.wasm": { - "raw": 255857, - "gzip": 84237 + "raw": 255484, + "gzip": 84026 } }, "openjphjs": { "openjphjs.js": { - "raw": 113863, - "gzip": 28916 + "raw": 114126, + "gzip": 28495 }, "openjphjs.wasm": { - "raw": 2296745, - "gzip": 673433 + "raw": 2295156, + "gzip": 672818 } } } diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index bbc6d43..83acc74 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -2,4 +2,5 @@ import { defineWorkspace } from "vitest/config" export default defineWorkspace([ "packages/*/vitest.config.mjs", + "tools/csp/vitest.config.mjs", ])