Skip to content

Phase 2: hardening — CSP/headers, /og, PostHog privacy, shared OAuth token - #6

Merged
alexandermayes merged 11 commits into
mainfrom
harden/phase-2
Jul 22, 2026
Merged

Phase 2: hardening — CSP/headers, /og, PostHog privacy, shared OAuth token#6
alexandermayes merged 11 commits into
mainfrom
harden/phase-2

Conversation

@alexandermayes

Copy link
Copy Markdown
Owner

Implements Phase 2 of PRODUCTION_HARDENING.md.

⚠️ Stacked on #5 (Phase 1). Base is harden/phase-1, so this diff shows only Phase 2 changes. Merge #5 first, then this retargets cleanly to main (or I'll retarget it).

Same ground rules held: no API response-shape changes, graceful degradation without Redis, no new deps.

2.1 — Security headers (next.config.ts)

nosniff, Referrer-Policy, Permissions-Policy on every route; X-Frame-Options: SAMEORIGIN + Content-Security-Policy-Report-Only on everything except /og (unfurlers fetch that image). CSP is report-only first — promote to enforcing manually after checking console/traffic. Verified at runtime: / carries all headers incl. CSP; /og carries only the benign three.

2.2 — /og input hardening

Validate report against the code regex (invalid → branded fallback, never fetch); player card only when fight+source are non-negative integers; self-fetch origin pinned to https://parseforge.gg in prod (request origin in dev only) so a spoofed Host can't steer it.

2.3 — Integer-coerce ids in the built CLA query

buildCLABuffUptimeQuery now Number()-coerces and drops non-integer/negative ids — insurance for the only string-interpolated query.

2.4 — PostHog session-replay privacy

maskAllInputs: true (drops the report-URL un-masking) and enable_recording_console_log: false. TODO left for an EU consent banner (product decision).

2.5 — Shared WCL OAuth token

getAccessToken checks module scope → Redis (wcl:token) → mint+write-back, so cold instances reuse an existing token instead of each spending a token request. On 401, both copies are cleared before retry. Adds cacheDelete(). Module-only without Redis.

Verification

  • npx tsc --noEmit ✅ · npm run build ✅ · header behavior confirmed via next start + curl
  • npm run lint holds at the same 16 pre-existing findings — zero new
  • Note: CSP is Report-Only; confirm zero console violations on landing + an analyze page in a real browser before promoting to enforcing.

🤖 Generated with Claude Code

alexandermayes and others added 11 commits July 19, 2026 15:13
Add lib/rate-limit.ts — per-IP sliding-window limiting backed by the same
Upstash Redis the cache uses (env resolution mirrors kv-cache.ts, incl. the
KV_REST_API_* names the Vercel Marketplace injects). No-ops when Redis is
unconfigured (local dev) and fails open on any Redis error, so it can never
take the site down. checkRateLimit() returns a 429 NextResponse (with
Retry-After) or null.

Wired into all five routes after body/param parsing, tuned per cost via
RATE_LIMITS in constants.ts: analyze/raid-overview 30/60s, cla 10/60s (biggest
fan-out), report + report-players 60/60s. Hits logged via logEvent for Vercel
visibility. The existing SWR hooks already surface `data.error`, so the 429
message displays without frontend changes.

Also drops the hardening plan in the repo root for tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add shared isValidReportCode/badRequest helpers to api-utils. On the three POST
routes, validate before building cache keys or querying WCL:
- analyze/raid-overview: reportCode matches the code regex and fightId/sourceId
  are integers (Number.isInteger keeps 0 valid).
- cla: guard that fightIds is a non-empty array of integers (a non-array body
  previously threw on .length → 500), dedupe, and reject > MAX_CLA_FIGHTS (15)
  so one request can't fan out unbounded WCL calls. The handler now queries the
  deduped/capped set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add cacheLock/cacheUnlock (SET NX EX 20 / DEL) to kv-cache, no-op without Redis.
In cachedApiHandler, on a cache miss with Redis configured: the lock holder
computes + caches while everyone else polls the cache (~500ms, up to 15s) and
returns it as cache:"wait_hit". Waiters that time out fall through and compute
themselves so no one dead-ends; the lock releases in finally with the EX TTL as
a crash backstop. Without Redis, behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
- Passive link replies: per-channel 30s cooldown, and only reply when the link
  has a fight (bare report links no longer trigger the bot). Reply wrapped in
  try/catch so a permissions error logs instead of crashing the process (an
  unhandled rejection terminates Node 20).
- Slash commands: per-user 10s cooldown with an ephemeral throttle notice; the
  whole dispatch is wrapped so a handler rejection can't crash the bot.
- api.ts: stop echoing raw upstream response bodies into Discord — throw a typed
  ApiError(status, cleanMessage) that surfaces only the API's `error` field, and
  add describeApiError() with a friendly 429 message. Both commands use it.
- Fix stale activity string getlootlist.com -> parseforge.gg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add a headers() block: X-Content-Type-Options, Referrer-Policy, and
Permissions-Policy on every route; X-Frame-Options: SAMEORIGIN and a
Content-Security-Policy-Report-Only on everything EXCEPT /og (link unfurlers
fetch the OG image). CSP ships report-only so violations log to the console
without blocking, to be promoted to enforcing manually after checking real
traffic. Verified: / carries all headers incl. CSP; /og carries only the benign
ones (frame-blocking correctly excluded via negative-lookahead source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Validate report against the code regex (invalid/absent → branded fallback,
never fetch); only take the player-scorecard path when fight+source parse to
non-negative integers; pin the self-fetch origin to https://parseforge.gg in
production (request origin only in dev) so a spoofed Host can't steer it. Keeps
the never-fail-an-unfurl catch-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
buildCLABuffUptimeQuery interpolates sourceIds into the query string. They come
from WCL's own actor list (safe today), but coerce each via Number() and drop
non-integers/negatives as insurance against a future caller passing unvalidated
data into the only string-built query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
maskAllInputs: true (drops the custom un-masking of the report-URL input) and
enable_recording_console_log: false so replays can't hoover up client-side
console output or typed input. TODO left re: EU consent banner (product call).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
getAccessToken now checks module scope, then Redis (wcl:token), then mints from
WCL and writes back to both — so a cold serverless instance reuses a token
another instance already minted instead of spending a fresh token request
(every mint counts against the same client). TTL is set just short of expiry.
On a 401, clearAccessToken() drops both the module and Redis copies before
retrying. Adds cacheDelete() to kv-cache. No Redis → module-only, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
parseforge Ready Ready Preview, Comment Jul 21, 2026 6:14pm

@alexandermayes
alexandermayes changed the base branch from harden/phase-1 to main July 22, 2026 05:44
@alexandermayes
alexandermayes merged commit 820eb59 into main Jul 22, 2026
3 checks passed
@alexandermayes
alexandermayes deleted the harden/phase-2 branch July 22, 2026 05:45
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