From 3d38e428dc8902ee6e1a34bce807dac819020016 Mon Sep 17 00:00:00 2001 From: MorganOnCode <87934408+MorganOnCode@users.noreply.github.com> Date: Fri, 15 May 2026 12:10:43 +0000 Subject: [PATCH] feat(landing): precompile JSX so CSP can drop 'unsafe-eval' Closes audit #13. Replaces Babel-standalone in the browser with a precompiled bundle so the production CSP can be tightened from: script-src: 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com https://unpkg.com to: script-src: 'self' https://static.cloudflareinsights.com That removes the eval class of attack surface entirely (Babel was compiling JSX in the browser via `new Function()`, which required 'unsafe-eval'), and drops the unpkg.com supply-chain dependency since React + ReactDOM are now bundled. ## Approach Zero source-file changes. The 8 .jsx files in landing/ still use the familiar `React.useState` / `ReactDOM.createRoot` global API; nothing was refactored to ESM imports. Instead a tiny `scripts/build-landing.mjs` concatenates the files in their existing load order, prepends a shim that imports React + ReactDOM, and feeds the result through esbuild. This minimises the diff (the .jsx code is untouched), keeps the dev workflow understandable (each component is still a self-contained .jsx file), and gives us a clean production artifact (landing/dist/app.js, ~185 KiB minified, ~438 KiB sourcemap). ## Files - `scripts/build-landing.mjs` -- new, ~80 lines, esbuild bundle of the concatenated .jsx with classic JSX transform - `package.json` -- adds react@18.3.1 + react-dom@18.3.1 as runtime deps, @types/react + @types/react-dom + esbuild as dev deps, a `build:landing` script, and chains it into `build` - `Dockerfile` -- build stage now copies `landing/` and the build script, runs `pnpm build` (both backend tsup AND landing esbuild), production stage pulls landing/ (including dist/) from the build stage instead of the host - `landing/index.html` -- drops 3 unpkg.com ` - - - - - - - - - - - + + + + diff --git a/package.json b/package.json index 77dcf89..e8d06f3 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ }, "scripts": { "dev": "tsx watch --env-file-if-exists=.env --inspect src/index.ts", - "build": "tsup", + "build": "tsup && pnpm build:landing", + "build:landing": "node scripts/build-landing.mjs", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.ts\"", @@ -94,12 +95,17 @@ "ioredis": "^5.10.1", "pino": "^10.3.1", "prom-client": "^15.1.3", + "react": "18.3.1", + "react-dom": "18.3.1", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^25.8.0", + "@types/react": "^18.3", + "@types/react-dom": "^18.3", "@vitest/coverage-v8": "^4.0.18", + "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-config-airbnb-extended": "^3.1.0", "eslint-plugin-import": "^2.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f707789..56d8c0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,12 @@ importers: prom-client: specifier: ^15.1.3 version: 15.1.3 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) zod: specifier: ^4.4.3 version: 4.4.3 @@ -75,9 +81,18 @@ importers: '@types/node': specifier: ^25.8.0 version: 25.8.0 + '@types/react': + specifier: ^18.3 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3 + version: 18.3.7(@types/react@18.3.28) '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.8.0)(tsx@4.22.0)(yaml@2.9.0)) + esbuild: + specifier: ^0.28.0 + version: 0.28.0 eslint: specifier: ^9.39.4 version: 9.39.4 @@ -1329,6 +1344,17 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -1869,6 +1895,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -3276,9 +3305,18 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -3397,6 +3435,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -5291,6 +5332,17 @@ snapshots: pg-protocol: 1.11.0 pg-types: 2.2.0 + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/responselike@1.0.3': dependencies: '@types/node': 25.8.0 @@ -5872,6 +5924,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -7426,8 +7480,18 @@ snapshots: quick-lru@5.1.1: {} + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-is@16.13.1: {} + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -7586,6 +7650,10 @@ snapshots: safe-stable-stringify@2.5.0: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + secure-json-parse@4.1.0: {} semver@6.3.1: {} diff --git a/scripts/build-landing.mjs b/scripts/build-landing.mjs new file mode 100755 index 0000000..f960324 --- /dev/null +++ b/scripts/build-landing.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// scripts/build-landing.mjs +// Precompile the landing-page JSX so we can drop 'unsafe-eval' from CSP. +// +// Strategy: concatenate the existing 8 .jsx files in the order they were +// loaded via