diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..841de00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test 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 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({ {" "} { + it("passes through http and https unchanged", () => { + expect(safeExternalUrl("https://example.com/x")).toBe( + "https://example.com/x", + ); + expect(safeExternalUrl("http://example.com")).toBe("http://example.com"); + }); + + it("blocks javascript: and other non-web schemes (the XSS guard)", () => { + expect(safeExternalUrl("javascript:alert(1)")).toBeUndefined(); + expect(safeExternalUrl(" javascript:alert(1)")).toBeUndefined(); + expect(safeExternalUrl("JavaScript:alert(1)")).toBeUndefined(); + expect(safeExternalUrl("java\tscript:alert(1)")).toBeUndefined(); + expect(safeExternalUrl("data:text/html,")).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/app/url.ts b/app/url.ts new file mode 100644 index 0000000..a570b1f --- /dev/null +++ b/app/url.ts @@ -0,0 +1,24 @@ +// Data-sourced URLs (a convention's `url`, a key-date `source` post) come from +// data.cons.fyi and are rendered as anchor `href`s. React does not block +// `javascript:` (or other non-web) URLs, so a poisoned value would become a +// script-execution sink on click. Only pass through http(s) URLs; anything +// else yields `undefined`, leaving the anchor inert. +// +// Only absolute URLs are supported: `new URL(url)` throws on a relative or +// protocol-relative value and we fail closed. That is fine for the current +// sinks (a convention `url` / key-date `source` are always absolute http(s)), +// but a relative URL entering the dataset would silently render inert. +export function safeExternalUrl( + url: string | null | undefined, +): string | undefined { + if (!url) return undefined; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return undefined; + } + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? url + : undefined; +} diff --git a/package.json b/package.json index c2eb6de..2d976be 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "type": "module", "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", @@ -78,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/public/_headers b/public/_headers new file mode 100644 index 0000000..6ef5728 --- /dev/null +++ b/public/_headers @@ -0,0 +1,20 @@ +# Cloudflare Pages security headers. https://developers.cloudflare.com/pages/configuration/headers/ +# +# 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. +# +# 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 + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none' diff --git a/scripts/inject-csp.mjs b/scripts/inject-csp.mjs new file mode 100644 index 0000000..1ca343b --- /dev/null +++ b/scripts/inject-csp.mjs @@ -0,0 +1,70 @@ +// 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. +// +// 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"; + +const OUT = "build/client"; +const html = readFileSync(`${OUT}/index.html`, "utf8"); + +// Every inline