Skip to content

Web security hardening: headers/CSP, URL scheme guard, tests - #6

Merged
sparkyfen merged 6 commits into
consfyi:mainfrom
sparkyfen:security/web-hardening
Jul 17, 2026
Merged

Web security hardening: headers/CSP, URL scheme guard, tests#6
sparkyfen merged 6 commits into
consfyi:mainfrom
sparkyfen:security/web-hardening

Conversation

@sparkyfen

@sparkyfen sparkyfen commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Security hardening for the web client (from an internal review).

  • Security headers (public/_headers): X-Frame-Options: DENY, an enforced CSP frame-ancestors 'none' + base-uri/object-src, nosniff, Referrer-Policy — clickjacking + hardening, behaviour-neutral.
  • External-URL scheme guard (safeExternalUrl): data-sourced anchor hrefs pass through an http(s)-only guard, so a non-web-scheme value can't become a clickable sink. Applied at the four data-sourced sinks; fixed-scheme bsky/github links unaffected. Unit-tested.
  • Full CSP via build-time hashing, shipped REPORT-ONLY: a postbuild step pins script-src to the sha256 of each inline bootstrap script plus scoped resource directives, emitted as Content-Security-Policy-Report-Only. So the strict policy is observed (not enforced) and cannot break the app; the enforced header stays the safe tier. Verified under wrangler pages dev with zero violations across list/detail/map. Violations surface in the browser console; promote to enforced once it runs clean against real traffic incl. the OAuth login.
  • Tests + CI: a vitest unit test for the scheme guard, run via a new ci workflow on pull_request.

Verified: pnpm typecheck, pnpm test (3 passing), production build + CSP drive.

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 consfyi#3.
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 consfyi#2.
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' <hashes> 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 consfyi#3 (full CSP).
The href scheme guard from the previous commit had no regression test and
the repo had no test runner. Adds vitest + a unit test covering the
security-critical branches (javascript:/data:/vbscript: and whitespace/case
variants blocked, http(s) passed through, relative/empty/nullish fail
closed), a standalone vitest.config.ts (so tests don't load the app build
plugins), and a ci.yml running typecheck + test on pull_request so the
guard's regressions are caught in CI.

Follow-up to security review 2026-07-14 finding consfyi#2.
Keeps review screenshots and wrangler pages dev state out of the tree.
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 consfyi#3.
@sparkyfen
sparkyfen force-pushed the security/web-hardening branch from e239e49 to a57b9f6 Compare July 17, 2026 04:42
@sparkyfen
sparkyfen merged commit 6cf6b60 into consfyi:main Jul 17, 2026
2 checks passed
@sparkyfen
sparkyfen deleted the security/web-hardening branch July 17, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant