Phase 2: hardening — CSP/headers, /og, PostHog privacy, shared OAuth token - #6
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Phase 2 of
PRODUCTION_HARDENING.md.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-Policyon every route;X-Frame-Options: SAMEORIGIN+Content-Security-Policy-Report-Onlyon 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;/ogcarries only the benign three.2.2 —
/oginput hardeningValidate
reportagainst the code regex (invalid → branded fallback, never fetch); player card only whenfight+sourceare non-negative integers; self-fetch origin pinned tohttps://parseforge.ggin prod (request origin in dev only) so a spoofed Host can't steer it.2.3 — Integer-coerce ids in the built CLA query
buildCLABuffUptimeQuerynowNumber()-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) andenable_recording_console_log: false. TODO left for an EU consent banner (product decision).2.5 — Shared WCL OAuth token
getAccessTokenchecks 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. AddscacheDelete(). Module-only without Redis.Verification
npx tsc --noEmit✅ ·npm run build✅ · header behavior confirmed vianext start+ curlnpm run lintholds at the same 16 pre-existing findings — zero new🤖 Generated with Claude Code