Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jobs:
"babel.config.json"
"lerna.json"
"tools/ci/"
"tools/csp/"
"tools/dist-size/"
"tools/browser-smoke/"
"tools/fixture-verification/"
Expand Down
5 changes: 5 additions & 0 deletions packages/charls/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion packages/charls/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
5 changes: 5 additions & 0 deletions packages/libjpeg-turbo-12bit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions packages/libjpeg-turbo-12bit/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions packages/libjpeg-turbo-8bit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion packages/libjpeg-turbo-8bit/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
5 changes: 5 additions & 0 deletions packages/openjpeg/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion packages/openjpeg/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
5 changes: 5 additions & 0 deletions packages/openjphjs/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions packages/openjphjs/build.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/bin/sh
set -e

mkdir -p build
mkdir -p dist
(cd build && CXXFLAGS=-msimd128 emcmake cmake -DCMAKE_BUILD_TYPE=Debug ..)
Expand All @@ -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
Comment thread
sedghi marked this conversation as resolved.
31 changes: 24 additions & 7 deletions tools/browser-smoke/run.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand All @@ -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)));
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Expand All @@ -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);
})();
48 changes: 48 additions & 0 deletions tools/csp/check-generated-js.js
Original file line number Diff line number Diff line change
@@ -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 <dist-directory>");
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*,/],
];
Comment thread
sedghi marked this conversation as resolved.

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`);
52 changes: 52 additions & 0 deletions tools/csp/check-generated-js.test.js
Original file line number Diff line number Diff line change
@@ -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")
})
})
8 changes: 8 additions & 0 deletions tools/csp/vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"

export default defineConfig({
test: {
name: "csp",
include: ["*.test.js"],
},
})
Loading
Loading