Skip to content

fix(csrf): fail closed when CSRF_SECRET is unset - #938

Merged
nanaf6203-bit merged 2 commits into
MettaChain:mainfrom
amossamuel851-tech:fix/issue-817-csrf-fail-closed
Aug 27, 2026
Merged

fix(csrf): fail closed when CSRF_SECRET is unset#938
nanaf6203-bit merged 2 commits into
MettaChain:mainfrom
amossamuel851-tech:fix/issue-817-csrf-fail-closed

Conversation

@amossamuel851-tech

Copy link
Copy Markdown
Contributor

Summary

Closes #817

Removes the hardcoded fallback CSRF secret from src/lib/csrf.ts so tokens can no longer be minted or verified with a publicly-known key, and makes the module fail closed when CSRF_SECRET is unset: minting throws a clear error (no token issued) and verification returns false (all protected write handlers keep returning 403). The same change makes CSRF_SECRET required in scripts/validate-env.js and documents it in .env.example, so deployments that never set the variable are caught before the enforcement lands.

The key design decision: enforcement lives at the mint/verify boundary (reusing the existing requireEnvStrict helper from src/lib/requireEnv.ts) rather than at module load. That keeps the security guarantee unconditional — no code path can ever produce or accept a token without a configured secret — without breaking next build for environments that are mid-migration, while validate-env.js gives operators the early, explicit failure.

Why

src/lib/csrf.ts line 4 previously derived the secret as process.env.CSRF_SECRET || 'default-fallback-csrf-secret-key-32-chars-long!'. In any deployment where the env var is unset, every CSRF token was signed with a key committed to the repository, so an attacker could forge valid tokens for every state-changing request protected by this module — making the CSRF defense equivalent to not having it. The failure was silent: minting and verification both "worked" with plausible-looking values. This is the same defect class the issue notes was already fixed for JWT_SECRET in the backend, but csrf.ts was a distinct, still-live instance.

What was built

File What it contains
src/lib/csrf.ts Removes the hardcoded fallback. generateTokenForSession now reads the secret lazily via the existing requireEnvStrict('CSRF_SECRET'), throwing Missing required environment variable: CSRF_SECRET when unset — so no token is ever minted with a guessable key. validateCsrf catches that and returns false, so the withCsrf wrapper fails closed with a 403 instead of crashing. Token format is unchanged (HMAC-SHA256 over sessionId:authState), so behavior with a configured secret is identical. Also tightened the pre-existing any in the withCsrf generic to unknown, which removes the only remaining no-explicit-any warnings in the file.
src/lib/__tests__/csrf.test.ts New suite (14 tests) covering: fail-closed minting when CSRF_SECRET is unset or empty; correct minting with a configured secret; valid-token acceptance; rejection of tokens signed with the old hardcoded fallback; fail-closed verification when the secret is removed at runtime; missing header/session; tampered tokens; withCsrf 403/200 behavior; and a regression guard asserting the fallback literal is absent from the source.
scripts/validate-env.js Adds CSRF_SECRET to the env schema with a required (non-empty) validator, so npm run validate:env exits 1 with CSRF_SECRET: Invalid value when unset and exits 0 when set (verified).
.env.example Documents CSRF_SECRET as required for CSRF protection, with a generation hint (openssl rand -hex 32).

The tests are written against the exact security property from the issue — a forged token using the old committed fallback string must be rejected — and the implementation/tests are in lockstep: the fail-closed tests fail against the pre-fix code (verified) and pass against this change.

Integration changes outside src/lib/

  • scripts/validate-env.jsCSRF_SECRET is now a required environment variable in the schema.
  • .env.example — new Security / CSRF Protection section documenting CSRF_SECRET.
  • src/lib/__tests__/csrf.test.ts — new test file; no other tests were modified.

No existing source files outside src/lib/csrf.ts were modified.

Acceptance criteria coverage

  • With CSRF_SECRET unset, token minting/verification fails closed (no tokens issued, or a clear error), and the literal fallback string is gone from src/lib/csrf.ts (src/lib/__tests__/csrf.test.ts — "fails closed (throws) when CSRF_SECRET is unset", "fails closed when CSRF_SECRET is unset, even with a previously valid token", "returns 403 when CSRF_SECRET is unset", "no longer contains the hardcoded fallback secret")
  • With CSRF_SECRET set, current behavior is unchanged (tokens mint and verify) (csrf.test.ts — "mints an HMAC-SHA256 token when CSRF_SECRET is set", "accepts a valid token minted with the configured secret", "invokes the handler when CSRF validation passes")
  • A test proves a token signed with the old fallback string is rejected when the real secret is configured (csrf.test.ts — "rejects a token signed with the old hardcoded fallback secret")
  • scripts/validate-env.js (or the equivalent env documentation) requires CSRF_SECRET (scripts/validate-env.js schema entry; verified exit 1 without / exit 0 with; documented in .env.example)
  • npm run typecheck, npm test, and npm run lint pass — not fully satisfiable in this repository as committed; see Test plan. This change introduces zero new failures across all three gates (verified by baseline comparison).

Test plan

  • npx jest src/lib/__tests__/csrf.test.ts14/14 passing (14 new tests)
  • npm test834/973 passing, 139 failing (65 suites) — all failures pre-existing: the identical baseline without my changes is 828/973 passing, 145 failing (66 suites), so this change introduces 0 new failures and adds 6 previously-failing assertions to the passing set. Pre-existing failures are unrelated (e.g. the viem mock lacks defineChain; several component/error-boundary suites).
  • npm run typecheck36 errors, all pre-existing in 8 untouched files (src/stories/ResponsiveContainerExample.stories.ts, src/lib/toast.ts, src/components/PropertyCard.tsx, src/components/TransactionConfirmation.tsx, src/app/compare/page.tsx, and 3 others). Verified by stashing my changes: identical 36 errors without them. 0 errors in or caused by my files.
  • npm run lintcannot run on main as committed: eslint.config.mjs imports eslint-plugin-jsdoc, which is not declared in package.json, the lockfile, or installed — ERR_MODULE_NOT_FOUND on a clean checkout. After installing it locally with --no-save (no repo changes), my changed files lint clean: src/lib/csrf.ts, src/lib/__tests__/csrf.test.ts, scripts/validate-env.js all exit 0 (the repo-wide run reports ~3555 pre-existing violations).
  • npm run build — blocked by the pre-existing typecheck errors above (build runs typecheck first). npx next build additionally fails on a pre-existing ioredis bundling issue (Can't resolve 'dns'/'fs'/'net') in untouched code paths.

Env vars / Notes

CSRF_SECRET=<openssl rand -hex 32>
  • New required variable: CSRF_SECRET (HMAC signing secret for CSRF tokens). Generate with openssl rand -hex 32 or an equivalent CSPRNG.
  • Deployment ordering: operators must set CSRF_SECRET before deploying this change; without it, the CSRF token endpoint returns an error, all withCsrf-protected write routes return 403, and npm run validate:env exits 1. No migration is required — this is a configuration requirement, not a data-shape change.
  • Intentionally untouched: the token scheme itself (HMAC-SHA256 double-submit, unchanged), src/config/env/schema.ts (the Zod env schema is loaded at module init across many modules; making CSRF_SECRET required there would break next build for environments still mid-migration, which is exactly what validate-env.js is for), and the pre-existing any-related lint state elsewhere in the repo.
  • The old fallback literal appears only in the test file as the forgery vector it proves is rejected; it is gone from production code.

Remove the hardcoded fallback CSRF secret so tokens can no longer be
minted or verified with a publicly-known key. Minting now throws via
the existing requireEnvStrict helper, and verification returns false,
while validate-env.js and .env.example require the variable.

Closes MettaChain#817

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
@drips-wave

drips-wave Bot commented Aug 26, 2026

Copy link
Copy Markdown

@amossamuel851-tech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@nanaf6203-bit nanaf6203-bit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, thanks for getting this over the line!

@nanaf6203-bit nanaf6203-bit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@nanaf6203-bit
nanaf6203-bit merged commit 0fd54dc into MettaChain:main Aug 27, 2026
2 of 5 checks passed
@github-actions

Copy link
Copy Markdown

🔒 Preview Environment Destroyed

The preview environment for this PR has been torn down.

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.

CSRF protection uses a hardcoded fallback secret: tokens are forgeable when CSRF_SECRET is unset

2 participants