From 415d9c6a1bc00b6eadc56997ad9c95e9caef2ec8 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Tue, 14 Jul 2026 13:40:17 -0700 Subject: [PATCH 1/6] web: add security headers (clickjacking + base-uri/object-src) Adds public/_headers with X-Frame-Options: DENY, a CSP frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy, base-uri 'self' and object-src 'none'. These are behaviour-neutral (they do not govern script/style/img/connect loading). The resource-directive CSP that would backstop XSS is drafted in the file, disabled pending build-hash finalisation and in-app review (a tight script-src breaks Mantine's inline ColorSchemeScript and the React Router bootstrap). Addresses security review 2026-07-14 finding #3. --- public/_headers | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 public/_headers diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..0a4aedb --- /dev/null +++ b/public/_headers @@ -0,0 +1,32 @@ +# Cloudflare Pages security headers. https://developers.cloudflare.com/pages/configuration/headers/ +# +# Two tiers. The active block below is behaviour-neutral: none of these +# directives govern how scripts/styles/images/connections load, so they cannot +# break the app. They deliver clickjacking protection and base-uri/object +# hardening today. +# +# The resource-directive CSP (script-src/style-src/connect-src/img-src/...) that +# would additionally backstop XSS is drafted at the bottom and left disabled: a +# too-tight script-src breaks Mantine's inline ColorSchemeScript and the React +# Router bootstrap, so it must be finalised against a real build and reviewed in +# the running app before enabling. + +/* + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none' + +# --- DRAFT: full CSP pending build-hash finalisation + in-app review --------- +# Replace the Content-Security-Policy line above with a single line built from: +# default-src 'self'; +# base-uri 'self'; +# object-src 'none'; +# frame-ancestors 'none'; +# script-src 'self' ; +# style-src 'self' 'unsafe-inline'; # Mantine/emotion inline style attrs + vanilla-extract +# img-src 'self' data: blob: https:; # bsky avatars, flag data URIs, map tiles +# font-src 'self' data: https://protomaps.github.io; +# connect-src 'self' https:; # data.cons.fyi, bsky appview, and arbitrary OAuth PDS host +# worker-src 'self' blob:; # maplibre web workers +# frame-src 'none'; From fa9602e545ce5c9dee14206102b807154d69a59d Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Tue, 14 Jul 2026 13:40:17 -0700 Subject: [PATCH 2/6] web: validate external URL scheme before using as href Data-sourced URLs (a convention's url, a key-date source post) from data.cons.fyi were passed straight to Anchor href. React does not block javascript: URLs, so a poisoned value would be a script-execution sink on click. safeExternalUrl() passes through only http(s) URLs; anything else yields undefined, leaving the anchor inert. Applied at the four data-sourced sinks (EventRow website link, EventDetails source post + official-site links). Fixed-scheme bsky.app/github.com template hrefs are unaffected (React attribute-escaping keeps them inert). Addresses security review 2026-07-14 finding #2. --- app/components/EventDetails.tsx | 7 ++++--- app/components/EventRow.tsx | 3 ++- app/url.ts | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 app/url.ts diff --git a/app/components/EventDetails.tsx b/app/components/EventDetails.tsx index 58260ad..92d974e 100644 --- a/app/components/EventDetails.tsx +++ b/app/components/EventDetails.tsx @@ -35,6 +35,7 @@ import LikeButton from "~/components/LikeButton"; import SimpleErrorBoundary from "~/components/SimpleErrorBoundary"; import { LABELER_DID } from "~/config"; import { Profile } from "~/endpoints"; +import { safeExternalUrl } from "~/url"; import { type Event, useFollowedEventAttendeesDLE, @@ -363,7 +364,7 @@ function KeyDatesSection({ event }: { event: Event }) { <> {" "} Source post}> - + Dates are pulled from the convention’s Bluesky. Always confirm on the{" "} - + official site . @@ -495,7 +496,7 @@ export function Body({ event }: { event: Event }) { diff --git a/app/components/EventRow.tsx b/app/components/EventRow.tsx index fbdd1f4..cbf8a6b 100644 --- a/app/components/EventRow.tsx +++ b/app/components/EventRow.tsx @@ -26,6 +26,7 @@ import Avatar from "~/components/Avatar"; import Flag from "~/components/Flag"; import LikeButton from "~/components/LikeButton"; import { type Event, useFollowedEventAttendeesDLE, useNow } from "~/hooks"; +import { safeExternalUrl } from "~/url"; import * as classes from "./EventRow.css"; import GuessedEventMarker from "./GuessedEventMarker"; import { getExtendedRequestedLocales } from "./LinguiProvider"; @@ -208,7 +209,7 @@ export default function EventRow({ {" "} Date: Tue, 14 Jul 2026 14:28:23 -0700 Subject: [PATCH 3/6] web: enable full CSP via build-time inline-script hashing Cloudflare Pages serves static files, so a per-request nonce is impossible and 3 of the 5 inline bootstrap scripts embed per-build content hashes. scripts/inject-csp.mjs (postbuild hook) computes the sha256 of every inline script in the built index.html and rewrites build/client/_headers with the full policy: script-src 'self' plus scoped style/img/connect/font/ worker directives. javascript: URLs match neither 'self' nor any hash, so the CSP now backstops the anchor sinks safeExternalUrl already guards. The committed public/_headers keeps only the safe, behaviour-neutral tier, so a build that skips postbuild falls back to a valid CSP, never a broken one. Verified under wrangler pages dev: zero CSP violations across the list, con detail, and map (Mantine theme, React Router bootstrap, and maplibre/protomaps all load clean). Addresses security review 2026-07-14 finding #3 (full CSP). --- package.json | 1 + public/_headers | 32 +++++++---------------- scripts/inject-csp.mjs | 59 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 scripts/inject-csp.mjs diff --git a/package.json b/package.json index c2eb6de..ef7789b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "react-router build", + "postbuild": "node scripts/inject-csp.mjs", "dev": "react-router dev", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "react-router-serve ./build/server/index.js", diff --git a/public/_headers b/public/_headers index 0a4aedb..9d2e460 100644 --- a/public/_headers +++ b/public/_headers @@ -1,32 +1,18 @@ # Cloudflare Pages security headers. https://developers.cloudflare.com/pages/configuration/headers/ # -# Two tiers. The active block below is behaviour-neutral: none of these -# directives govern how scripts/styles/images/connections load, so they cannot -# break the app. They deliver clickjacking protection and base-uri/object -# hardening today. +# The Content-Security-Policy below is the safe, behaviour-neutral tier: it does +# not govern how scripts/styles/images/connections load, so it cannot break the +# app, and it delivers clickjacking + base-uri/object hardening on its own. # -# The resource-directive CSP (script-src/style-src/connect-src/img-src/...) that -# would additionally backstop XSS is drafted at the bottom and left disabled: a -# too-tight script-src breaks Mantine's inline ColorSchemeScript and the React -# Router bootstrap, so it must be finalised against a real build and reviewed in -# the running app before enabling. +# At build time scripts/inject-csp.mjs (run via the `postbuild` npm hook) +# rewrites the Content-Security-Policy line in the built build/client/_headers +# with the full policy — including a script-src pinned to the sha256 of each +# inline bootstrap script, the actual XSS backstop. If that step is ever skipped, +# the deployed site falls back to this safe tier (still valid, app still works), +# never a broken CSP. /* X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none' - -# --- DRAFT: full CSP pending build-hash finalisation + in-app review --------- -# Replace the Content-Security-Policy line above with a single line built from: -# default-src 'self'; -# base-uri 'self'; -# object-src 'none'; -# frame-ancestors 'none'; -# script-src 'self' ; -# style-src 'self' 'unsafe-inline'; # Mantine/emotion inline style attrs + vanilla-extract -# img-src 'self' data: blob: https:; # bsky avatars, flag data URIs, map tiles -# font-src 'self' data: https://protomaps.github.io; -# connect-src 'self' https:; # data.cons.fyi, bsky appview, and arbitrary OAuth PDS host -# worker-src 'self' blob:; # maplibre web workers -# frame-src 'none'; diff --git a/scripts/inject-csp.mjs b/scripts/inject-csp.mjs new file mode 100644 index 0000000..b564802 --- /dev/null +++ b/scripts/inject-csp.mjs @@ -0,0 +1,59 @@ +// Build-time CSP finalisation. Run as the `postbuild` npm hook after +// `react-router build`. Cloudflare Pages serves the app as static files, so a +// per-request nonce is impossible; instead we pin script-src to the sha256 of +// every inline bootstrap script in the built index.html (Mantine's +// ColorSchemeScript, the React Router context/stream scripts, whose contents +// change per build). Same-origin bundle files load via 'self'; a javascript: +// URL matches neither 'self' nor any hash, so it stays blocked — the XSS +// backstop for the anchor sinks safeExternalUrl already guards. +// +// This rewrites only the built artifact (build/client/_headers). The committed +// public/_headers keeps the safe, behaviour-neutral tier, so if this step is +// ever skipped the deploy falls back to a valid CSP rather than a broken one. +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; + +const OUT = "build/client"; +const html = readFileSync(`${OUT}/index.html`, "utf8"); + +// Every inline ")).toBeUndefined(); + expect(safeExternalUrl("vbscript:msgbox")).toBeUndefined(); + }); + + it("fails closed on empty, nullish, relative and unparseable input", () => { + expect(safeExternalUrl(undefined)).toBeUndefined(); + expect(safeExternalUrl(null)).toBeUndefined(); + expect(safeExternalUrl("")).toBeUndefined(); + expect(safeExternalUrl("not a url")).toBeUndefined(); + expect(safeExternalUrl("//protocol-relative.example")).toBeUndefined(); + expect(safeExternalUrl("/relative/path")).toBeUndefined(); + }); +}); diff --git a/package.json b/package.json index ef7789b..2d976be 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "react-router build", "postbuild": "node scripts/inject-csp.mjs", + "test": "vitest run", "dev": "react-router dev", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "react-router-serve ./build/server/index.js", @@ -79,7 +80,8 @@ "vite": "^6.0.0", "vite-plugin-babel": "^1.3.1", "vite-plugin-babel-macros": "^1.0.6", - "vite-tsconfig-paths": "^4.2.1" + "vite-tsconfig-paths": "^4.2.1", + "vitest": "^4.1.10" }, "engines": { "node": ">=20.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b789e6..ddacaee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: vite-tsconfig-paths: specifier: ^4.2.1 version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.1.0)(vite@6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3)) packages: @@ -691,6 +694,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -1059,6 +1065,9 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tabler/icons-react@3.33.0': resolution: {integrity: sha512-ay+HDecCjmFl25Lg14hcl59ffSjnOcgfrlV14shu8Qjbz+Xh4LRus93DuoyLQte8YSxE7Pe5gnEz6OF0GtwNtw==} peerDependencies: @@ -1082,6 +1091,12 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/deep-equal@1.0.4': resolution: {integrity: sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==} @@ -1323,6 +1338,35 @@ packages: maplibre-gl: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1426,6 +1470,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + assign-symbols@1.0.0: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} engines: {node: '>=0.10.0'} @@ -1545,6 +1593,10 @@ packages: caniuse-lite@1.0.30001720: resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1820,6 +1872,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1954,6 +2009,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1970,6 +2028,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@4.21.2: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} @@ -2010,6 +2072,15 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -2567,6 +2638,9 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + maplibre-gl@5.6.1: resolution: {integrity: sha512-TTSfoTaF7RqKUR9wR5qDxCHH2J1XfZ1E85luiLOx0h8r50T/LnwAwwfV0WVNh9o8dA7rwt57Ucivf1emyeukXg==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} @@ -2753,6 +2827,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -2878,6 +2956,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -3248,6 +3330,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3305,10 +3390,16 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3400,13 +3491,28 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -3670,6 +3776,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vt-pbf@3.1.3: resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} @@ -3713,6 +3860,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4285,6 +4437,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -4715,6 +4869,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@standard-schema/spec@1.1.0': {} + '@tabler/icons-react@3.33.0(react@19.1.0)': dependencies: '@tabler/icons': 3.33.0 @@ -4748,6 +4904,13 @@ snapshots: dependencies: '@babel/types': 7.27.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/deep-equal@1.0.4': {} '@types/estree@1.0.7': {} @@ -5047,6 +5210,47 @@ snapshots: optionalDependencies: maplibre-gl: 5.6.1 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -5165,6 +5369,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + assign-symbols@1.0.0: {} ast-types-flow@0.0.8: {} @@ -5300,6 +5506,8 @@ snapshots: caniuse-lite@1.0.30001720: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5606,6 +5814,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5839,6 +6049,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + esutils@2.0.3: {} etag@1.8.1: {} @@ -5850,6 +6064,8 @@ snapshots: exit-hook@2.2.1: {} + expect-type@1.4.0: {} + express@4.21.2: dependencies: accepts: 1.3.8 @@ -5923,6 +6139,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -6485,6 +6705,10 @@ snapshots: lru-cache@7.18.3: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + maplibre-gl@5.6.1: dependencies: '@mapbox/geojson-rewind': 0.5.2 @@ -6683,6 +6907,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.4: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -6803,6 +7029,8 @@ snapshots: picomatch@4.0.2: {} + picomatch@4.0.5: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -7227,6 +7455,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7279,8 +7509,12 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + statuses@2.0.1: {} + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7399,14 +7633,25 @@ snapshots: through@2.3.8: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.5(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinyqueue@3.0.0: optional: true + tinyrainbow@3.1.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -7672,6 +7917,33 @@ snapshots: terser: 5.43.1 tsx: 4.20.3 + vitest@4.1.10(@types/node@24.1.0)(vite@6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.3.5(@types/node@24.1.0)(jiti@1.21.7)(sugarss@4.0.1(postcss@8.5.4))(terser@5.43.1)(tsx@4.20.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.1.0 + transitivePeerDependencies: + - msw + vt-pbf@3.1.3: dependencies: '@mapbox/point-geometry': 0.1.0 @@ -7745,6 +8017,11 @@ snapshots: isexe: 3.1.1 optional: true + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c50d35e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +// Standalone config so unit tests don't pull in the React Router / lingui / +// vanilla-extract build plugins from vite.config.ts. +export default defineConfig({ + test: { + include: ["app/**/*.test.ts"], + environment: "node", + }, +}); From 2056737f4f9511666589404a6dc9dc04ce68960b Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 16 Jul 2026 15:34:05 -0700 Subject: [PATCH 5/6] web: gitignore local tooling artifacts (.ship, .wrangler) Keeps review screenshots and wrangler pages dev state out of the tree. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 412adbe..96d112e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ node_modules .env /app/locales/*/*.json /.react-router + +# local tooling artifacts (review screenshots, wrangler dev state) +/.ship +/.wrangler From a57b9f600facaa57b69f581f5dc2d86ffa6a0664 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 16 Jul 2026 15:50:00 -0700 Subject: [PATCH 6/6] web: ship strict CSP as report-only for initial rollout The postbuild step now emits the full strict policy (script-src hashes + scoped directives) as Content-Security-Policy-Report-Only instead of enforcing it, and keeps only the behaviour-neutral safe tier (frame-ancestors/base-uri/object-src) as the enforced Content-Security-Policy. Report-only cannot block anything, so this is safe on untested routes (the OAuth login + authed actions were not driven under CSP). Promote the strict policy to the enforced header once it runs clean against real traffic. Refines security review 2026-07-14 finding #3. --- public/_headers | 14 ++++++++------ scripts/inject-csp.mjs | 23 +++++++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/public/_headers b/public/_headers index 9d2e460..6ef5728 100644 --- a/public/_headers +++ b/public/_headers @@ -4,12 +4,14 @@ # not govern how scripts/styles/images/connections load, so it cannot break the # app, and it delivers clickjacking + base-uri/object hardening on its own. # -# At build time scripts/inject-csp.mjs (run via the `postbuild` npm hook) -# rewrites the Content-Security-Policy line in the built build/client/_headers -# with the full policy — including a script-src pinned to the sha256 of each -# inline bootstrap script, the actual XSS backstop. If that step is ever skipped, -# the deployed site falls back to this safe tier (still valid, app still works), -# never a broken CSP. +# At build time scripts/inject-csp.mjs (run via the `postbuild` npm hook) ADDS a +# second header to the built build/client/_headers: +# Content-Security-Policy-Report-Only carrying the full policy — a script-src +# pinned to the sha256 of each inline bootstrap script (the XSS backstop) plus +# scoped resource directives. Report-only only reports violations, so it cannot +# break the app; once it runs clean against real traffic (incl. the OAuth login), +# promote it to the enforced header below. If the build step is skipped, only +# this enforced safe tier ships — still valid, never a broken CSP. /* X-Frame-Options: DENY diff --git a/scripts/inject-csp.mjs b/scripts/inject-csp.mjs index b564802..1ca343b 100644 --- a/scripts/inject-csp.mjs +++ b/scripts/inject-csp.mjs @@ -7,9 +7,14 @@ // URL matches neither 'self' nor any hash, so it stays blocked — the XSS // backstop for the anchor sinks safeExternalUrl already guards. // -// This rewrites only the built artifact (build/client/_headers). The committed -// public/_headers keeps the safe, behaviour-neutral tier, so if this step is -// ever skipped the deploy falls back to a valid CSP rather than a broken one. +// ROLLOUT: the full strict policy ships as Content-Security-Policy-REPORT-ONLY, +// so it only reports violations and cannot break the app. The committed +// public/_headers keeps a small ENFORCED Content-Security-Policy (frame-ancestors +// etc.) so clickjacking protection is live now. Once report-only has run clean +// against real traffic (incl. the OAuth login + authed actions), promote the +// strict policy to the enforced header. This rewrites only the built artifact +// (build/client/_headers); if the step is skipped the deploy still serves the +// valid enforced safe tier from public/_headers. import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; @@ -49,11 +54,17 @@ const csp = [ const headersPath = `${OUT}/_headers`; const headers = readFileSync(headersPath, "utf8"); -if (!/^\s*Content-Security-Policy:.*$/m.test(headers)) { +const cspLine = /^(\s*)Content-Security-Policy:.*$/m; +if (!cspLine.test(headers)) { throw new Error("inject-csp: no Content-Security-Policy line found in _headers"); } +// Leave the committed enforced safe-tier CSP in place; add the full strict +// policy as report-only right below it (same indentation). Report-only can't +// block anything, so this is safe to ship to production untested routes. writeFileSync( headersPath, - headers.replace(/^(\s*)Content-Security-Policy:.*$/m, `$1Content-Security-Policy: ${csp}`), + headers.replace(cspLine, `$&\n$1Content-Security-Policy-Report-Only: ${csp}`), +); +console.log( + `inject-csp: added report-only strict CSP (script-src pinned to ${hashes.length} inline-script hashes)`, ); -console.log(`inject-csp: pinned script-src to ${hashes.length} inline-script hashes`);