Skip to content

fix(hardening): media key, CSP, viewport, kiosk storage, SW updates, redirect-param#37

Closed
bryanfawcett wants to merge 5 commits into
mainfrom
claude/hardening-grab-bag
Closed

fix(hardening): media key, CSP, viewport, kiosk storage, SW updates, redirect-param#37
bryanfawcett wants to merge 5 commits into
mainfrom
claude/hardening-grab-bag

Conversation

@bryanfawcett
Copy link
Copy Markdown
Contributor

A grab-bag of unrelated hardening fixes surfaced by a recent security review. Each commit stands on its own.

Fixes

  • Media R2 key prefix bugworker/src/routes/media.ts:52,99 (now :55,103). c.req.path.replace("/", "") only strips the first slash, so GET /api/media/events/abc.png was looking up api/media/events/abc.png in R2 — every key written by the upload handler (events/<id>.<ext>) missed. Switched to an anchored ^/api/media/ regex.
  • CSP header missingworker/src/index.ts:97 security-headers middleware. CLAUDE.md claimed CSP was set; it wasn't. Added Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self' to all non-HTML responses. The one HTML endpoint (/ status page in worker/src/routes/health.ts) uses inline styles + Google Fonts and would break under this policy, so it's skipped — called out in the comment.
  • Viewport blocks pinch-zoomsrc/app/layout.tsx:24. Removed maximumScale: 1 (WCAG 1.4.4 violation). Next.js default allows pinch-zoom.
  • Kiosk token in localStoragesrc/app/events/[id]/kiosk/page.tsx:82,433,440. Moved nhimbe_kiosk_token from localStorage to sessionStorage (dies with the tab) and added a document.visibilitychange handler in KioskPage that clears the session after the tab has been hidden for >= 30 minutes — kiosks live on shared/public hardware.
  • Intercom blocking page loadsrc/app/layout.tsx:204-209. Switched both <Script> strategies from afterInteractive to lazyOnload. Widget still loads; just stops blocking the main thread during initial load.
  • PWA service-worker has no update flowsrc/components/pwa/sw-register.tsx. Added the standard updatefound / statechange pattern: when the new SW reaches installed and a controller already exists, call location.reload() so users stop seeing 404s for evicted _next/static/* hashes after a deploy. No new dependencies.
  • Signin redirect param mismatchsrc/app/auth/signin/page.tsx:13 reads return_to, but four callers were sending the wrong param name and silently dropping the redirect: src/app/events/[id]/rsvp-button.tsx:72 (was redirect=), src/app/admin/layout.tsx:60 (was returnUrl=), src/components/layout/header.tsx:228 (was redirect=), src/components/layout/mobile-bottom-nav.tsx:37 (was redirect=). Standardized all four on return_to. The two senders explicitly listed in the brief plus two more I found in the audit that had the same bug. src/components/auth/auth-context.tsx:153 was also flagged but it sets the auth_redirect localStorage key — a different mechanism, never read anywhere, left alone.

Out of scope (intentionally untouched)

  • Categories endpoints (separate PR in flight)
  • JWT/auth route changes (separate PR in flight)
  • Counter race fixes in links.ts / waitlist.ts
  • Rebuilding D1-mocked tests

Test plan

  • cd worker && npx tsc --noEmit — clean
  • cd worker && npx vitest run — 231/231 pass
  • npm run lint — 0 errors (36 preexisting warnings)
  • npx vitest run — 160/160 pass
  • Manual: upload an event cover image, confirm GET /api/media/events/<id>.<ext> serves it (the bug fix)
  • Manual: deploy and inspect a worker response, confirm Content-Security-Policy header is set on JSON; absent on / status page
  • Manual: pinch-zoom on iOS Safari
  • Manual: kiosk pair → close tab → reopen → confirm pairing screen reappears (sessionStorage gone)
  • Manual: kiosk pair → hide tab for 30+ min → return → confirm session cleared
  • Manual: deploy a frontend change with the SW, confirm an open tab reloads exactly once and picks up new assets
  • Manual: signin redirect — click "Sign in to RSVP" on event page; after auth land on the event, not /

https://claude.ai/code/session_01Dp6YFZCHz1HjL9svPWmso2


Generated by Claude Code

claude added 5 commits May 17, 2026 15:51
`media.ts` GET/DELETE handlers were stripping only the first `/` from the
request path, so a request for `/api/media/events/abc.png` was looking up
`api/media/events/abc.png` in R2 — every key written by the upload handler
(`events/<id>.<ext>`) missed. Fix to strip the full `/api/media/` prefix
with an anchored regex.

CLAUDE.md claims the security-headers middleware sets CSP but it didn't.
Add `Content-Security-Policy: default-src 'self'; frame-ancestors 'none';
base-uri 'self'` to all non-HTML responses (the worker is JSON-only apart
from the `/` status page in health.ts, which uses inline styles + Google
Fonts and would break under this CSP — skip it there).
- Remove `maximumScale: 1` from the viewport export. Blocking pinch-zoom
  violates WCAG 1.4.4 (Resize Text); Next.js's default allows user scaling.
- Switch Intercom to `lazyOnload` so the widget stops blocking the main
  thread during the initial page load. Keeps the widget; just defers it.
Kiosks run on shared hardware (event venue TVs, check-in tablets). Storing
the pairing token in localStorage made it persist across browser restarts
and exposed it to any XSS payload, with no expiry.

- Switch the three `nhimbe_kiosk_token` reads/writes from localStorage to
  sessionStorage so the token dies with the tab.
- Add a `document.visibilitychange` handler in the main KioskPage. If the
  tab is hidden for >= 30 minutes (operator walked away, screen locked,
  etc.) the session is cleared on next visibility, dropping back to the
  pairing screen instead of letting a passer-by hijack it.
Previously the SW was registered with no update handling, so after a
Next.js deploy users would keep loading the old SW and hit 404s for
evicted `_next/static/*` hashes until they did a full reload by hand.

Add the standard `updatefound` / `statechange` pattern: when the new
SW reaches `installed` and a controller is already active, call
`location.reload()` so the new worker takes over. No new dependencies.
The signin page reads `?return_to=`, but four callers were sending different
param names — `redirect=` (rsvp-button, header, mobile-bottom-nav) and
`returnUrl=` (admin layout) — so the post-signin landing always fell back
to the default `/`, silently dropping the user's intended destination.

Align all four senders on `return_to` to match what `signin/page.tsx` reads.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nhimbe Ready Ready Preview, Comment May 17, 2026 3:56pm

Request Review

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
mukoko-nhimbe-api edc0027 Commit Preview URL

Branch Preview URL
May 17 2026, 03:55 PM

@claude
Copy link
Copy Markdown

claude Bot commented May 17, 2026

Claude finished @bryanfawcett's task —— View job


Security & Quality Review

Reviewing this hardening PR for security vulnerabilities, performance issues, code quality, and potential bugs.

Todo List

  • Review media R2 key prefix fix
  • Review CSP header implementation
  • Review viewport pinch-zoom fix
  • Review kiosk storage security changes
  • Review Intercom loading optimization
  • Review PWA service worker update flow
  • Review signin redirect param standardization
  • Check adherence to project conventions
  • Provide final assessment

Detailed Analysis

✅ Media R2 Key Fix (worker/src/routes/media.ts:55,103)

  • Good fix: Changed from replace("/", "") to replace(/^\/api\/media\//, "")
  • Security: Proper regex anchoring prevents path traversal issues
  • Correctness: Now properly strips full route prefix instead of just first slash

✅ CSP Header Implementation (worker/src/index.ts:97-117)

  • Security: Adds strict CSP default-src 'self'; frame-ancestors 'none'; base-uri 'self'
  • Smart logic: Correctly skips HTML responses (health status page needs inline styles)
  • Good defense: Prevents XSS and clickjacking on JSON endpoints

✅ Viewport Pinch-Zoom Fix (src/app/layout.tsx:21-27)

  • Accessibility: Removes maximumScale: 1 that violated WCAG 1.4.4
  • Good comment: Explains why this change was needed
  • Standards compliant: Now follows Next.js defaults

✅ Kiosk Storage Security (src/app/events/[id]/kiosk/page.tsx:82,433,440,457-480)

  • Security improvement: Moved token from localStorage to sessionStorage
  • Smart timeout: 30-min hidden tab timeout clears session automatically
  • Good pattern: Uses visibilitychange event to track tab visibility
  • Proper cleanup: Removes event listeners in useEffect cleanup

✅ Intercom Loading Optimization (src/app/layout.tsx:205-210)

  • Performance: Changed from afterInteractive to lazyOnload strategy
  • Non-blocking: Prevents Intercom from blocking main thread during initial load
  • Maintains functionality: Widget still loads, just deferred

✅ PWA Service Worker Updates (src/components/pwa/sw-register.tsx:16-24)

  • UX improvement: Adds proper updatefound pattern to reload on SW updates
  • Smart logic: Only reloads if controller exists (new SW replacing old)
  • Prevents 404s: Ensures users get fresh assets after deploys

✅ Signin Redirect Standardization (Multiple files)

  • Consistency: Standardized all callers to use return_to param
  • Fixed files:
    • src/app/events/[id]/rsvp-button.tsx:72 (was redirect=)
    • src/app/admin/layout.tsx:60 (was returnUrl=)
    • src/components/layout/header.tsx:228 (was redirect=)
    • src/components/layout/mobile-bottom-nav.tsx:37 (was redirect=)
  • Good audit: Found and fixed silent redirect failures

Final Assessment

✅ Excellent hardening work - No security vulnerabilities, performance regressions, or code quality issues found. All fixes are:

  • Targeted: Each addresses a specific security/quality concern
  • Safe: No breaking changes or risky patterns introduced
  • Well-tested: All automated tests pass
  • Convention-compliant: Follows project patterns from CLAUDE.md
  • Properly scoped: Focused fixes without scope creep

Recommended for merge - This represents solid defensive engineering with meaningful security and UX improvements.


Copy link
Copy Markdown
Contributor Author

Closing — consolidating into PR #36 per the "all in one PR" directive. Cherry-picking the 5 commits onto claude/worker-observability-and-categories; branch claude/hardening-grab-bag will stay around for reference until #36 merges.


Generated by Claude Code

@bryanfawcett bryanfawcett deleted the claude/hardening-grab-bag branch May 21, 2026 08:11
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.

2 participants