From 7d4e4e39682a5558a79812ef9eb23f64afb224e7 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Thu, 21 May 2026 14:36:08 +0530 Subject: [PATCH] fix(security): fail fast when JWT_SECRET is absent or insecure (closes #186) The jwt plugin was registered with a hard-coded fallback: secret: process.env.JWT_SECRET || 'dev-secret-change-me' Because the fallback string is committed to the public repository, any attacker could sign arbitrary JWTs for any userId and gain full authenticated access to every protected API endpoint. Changes: utils/validateEnv.ts (new) Exports a synchronous validateEnv() function that checks JWT_SECRET and ENCRYPTION_KEY before the Fastify instance is created. Missing or empty values trigger an immediate process.exit(1). In production (NODE_ENV=production), JWT_SECRET is also compared against the set of known insecure defaults shipped in the repository; a match is treated as a hard failure. All errors are collected and printed in a single exit so operators can fix everything in one deploy cycle. Secret values are never written to any output. app.ts Calls validateEnv() as the very first statement of buildApp(), before the Fastify instance is instantiated and before any plugin is registered. This guarantees that no partially-initialised auth state can exist: if validation fails, JWT is never configured. The now-redundant fallback is removed; process.env.JWT_SECRET! is used instead (the non-null assertion is safe because validateEnv() exits the process before returning when the value is absent). __tests__/validateEnv.test.ts (new) 11 focused tests covering: absent JWT_SECRET, empty JWT_SECRET, insecure default in production, insecure default allowed in dev/test, absent ENCRYPTION_KEY, empty ENCRYPTION_KEY, multi-secret failure (single exit call), happy-path in dev and production, and a check that secret values are never surfaced in console output. --- .../backend/src/__tests__/validateEnv.test.ts | 136 ++++++++++++++++++ apps/backend/src/app.ts | 9 +- apps/backend/src/utils/validateEnv.ts | 76 ++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/__tests__/validateEnv.test.ts create mode 100644 apps/backend/src/utils/validateEnv.ts diff --git a/apps/backend/src/__tests__/validateEnv.test.ts b/apps/backend/src/__tests__/validateEnv.test.ts new file mode 100644 index 00000000..eb0574bd --- /dev/null +++ b/apps/backend/src/__tests__/validateEnv.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { validateEnv } from '../utils/validateEnv.js'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** + * Replaces process.exit with a throwing stub for the duration of the test so + * that a failing validateEnv() call does not terminate the test process. + * Returns the spy so callers can assert the exit code. + */ +function stubExit() { + return vi.spyOn(process, 'exit').mockImplementation((code?: number | string) => { + throw new Error(`process.exit(${code})`); + }) as unknown as ReturnType; +} + +// ── test suite ──────────────────────────────────────────────────────────────── + +describe('validateEnv', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + // ─── JWT_SECRET ────────────────────────────────────────────────────────── + + it('exits with code 1 when JWT_SECRET is absent', () => { + vi.stubEnv('JWT_SECRET', undefined as unknown as string); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + const exit = stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('exits with code 1 when JWT_SECRET is an empty string', () => { + vi.stubEnv('JWT_SECRET', ''); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + }); + + it('exits with code 1 when JWT_SECRET is the known insecure default in production', () => { + vi.stubEnv('JWT_SECRET', 'dev-secret-change-me'); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + vi.stubEnv('NODE_ENV', 'production'); + stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + }); + + it('allows the known insecure default in non-production (development)', () => { + // The known-insecure check is production-only so local development still + // works with the default value without requiring a full secrets setup. + vi.stubEnv('JWT_SECRET', 'dev-secret-change-me'); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + vi.stubEnv('NODE_ENV', 'development'); + + // Must not throw / call process.exit + expect(() => validateEnv()).not.toThrow(); + }); + + it('allows the known insecure default when NODE_ENV is not set', () => { + vi.stubEnv('JWT_SECRET', 'dev-secret-change-me'); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + vi.stubEnv('NODE_ENV', undefined as unknown as string); + + expect(() => validateEnv()).not.toThrow(); + }); + + // ─── ENCRYPTION_KEY ────────────────────────────────────────────────────── + + it('exits with code 1 when ENCRYPTION_KEY is absent', () => { + vi.stubEnv('JWT_SECRET', 'a-valid-jwt-secret-that-is-sufficiently-long'); + vi.stubEnv('ENCRYPTION_KEY', undefined as unknown as string); + stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + }); + + it('exits with code 1 when ENCRYPTION_KEY is an empty string', () => { + vi.stubEnv('JWT_SECRET', 'a-valid-jwt-secret-that-is-sufficiently-long'); + vi.stubEnv('ENCRYPTION_KEY', ''); + stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + }); + + // ─── Multiple failures ──────────────────────────────────────────────────── + + it('reports both missing secrets in a single exit call', () => { + vi.stubEnv('JWT_SECRET', undefined as unknown as string); + vi.stubEnv('ENCRYPTION_KEY', undefined as unknown as string); + const exit = stubExit(); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + // A single exit — not one per error — so operators fix everything in one deploy. + expect(exit).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + }); + + // ─── Happy path ────────────────────────────────────────────────────────── + + it('passes when both secrets are valid in development', () => { + vi.stubEnv('JWT_SECRET', 'a-valid-jwt-secret-that-is-sufficiently-long'); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-32-char-encryption-key!!'); + vi.stubEnv('NODE_ENV', 'development'); + + expect(() => validateEnv()).not.toThrow(); + }); + + it('passes when both secrets are valid in production', () => { + vi.stubEnv('JWT_SECRET', 'a-long-random-production-jwt-secret-with-enough-entropy'); + vi.stubEnv('ENCRYPTION_KEY', 'a-64-char-hex-encryption-key-for-aes-256-gcm-0000000000000000'); + vi.stubEnv('NODE_ENV', 'production'); + + expect(() => validateEnv()).not.toThrow(); + }); + + // ─── No secret leakage ─────────────────────────────────────────────────── + + it('does not log the value of JWT_SECRET when reporting errors', () => { + const secretValue = 'super-secret-value-that-must-not-appear-in-logs'; + vi.stubEnv('JWT_SECRET', undefined as unknown as string); + vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + stubExit(); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => validateEnv()).toThrow('process.exit(1)'); + + const allOutput = errSpy.mock.calls.flat().join(' '); + expect(allOutput).not.toContain(secretValue); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2471a92d..734f5191 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -20,10 +20,16 @@ import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; import { nfcRoutes } from './routes/nfc.js'; import { eventRoutes } from './routes/event.js'; +import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function buildApp() { + // Validate all required secrets before registering any plugin. + // If validation fails the process exits here — no partially-initialised + // auth state can exist because Fastify is not yet instantiated. + validateEnv(); + const app = Fastify({ logger: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', @@ -58,7 +64,8 @@ export async function buildApp() { }); await app.register(jwt, { - secret: process.env.JWT_SECRET || 'dev-secret-change-me', + // validateEnv() above guarantees JWT_SECRET is present and safe. + secret: process.env.JWT_SECRET!, }); await app.register(cookie); diff --git a/apps/backend/src/utils/validateEnv.ts b/apps/backend/src/utils/validateEnv.ts new file mode 100644 index 00000000..cd361fc8 --- /dev/null +++ b/apps/backend/src/utils/validateEnv.ts @@ -0,0 +1,76 @@ +/** + * Startup environment validation. + * + * Validates all required secrets before the application registers any plugins. + * Missing or insecure values cause an immediate, deterministic process exit so + * the server never reaches a partially-initialised auth state. + * + * Call this at the very top of buildApp(), before any Fastify plugin registration. + */ + +/** + * Secrets that are committed to the public repository and must not be used in + * production. Any match triggers an immediate startup failure. + */ +const KNOWN_INSECURE_DEFAULTS: ReadonlySet = new Set([ + 'dev-secret-change-me', +]); + +/** + * Validates that all required secrets are present and safe. + * Exits the process with code 1 on any violation, logging all failures at once + * so operators can fix everything in a single deploy cycle. + * + * Secrets are never logged — only their presence and safety are reported. + */ +export function validateEnv(): void { + const errors: string[] = []; + const isProduction = process.env.NODE_ENV === 'production'; + + // ── JWT_SECRET ────────────────────────────────────────────────────────────── + const jwtSecret = process.env.JWT_SECRET; + + if (!jwtSecret) { + errors.push( + 'JWT_SECRET is not set. Generate a secure value with:\n' + + ' node -e "console.log(require(\'crypto\').randomBytes(64).toString(\'hex\'))"', + ); + } else if (isProduction && KNOWN_INSECURE_DEFAULTS.has(jwtSecret)) { + errors.push( + 'JWT_SECRET is set to a known insecure default and cannot be used in production.\n' + + ' Generate a secure value with:\n' + + ' node -e "console.log(require(\'crypto\').randomBytes(64).toString(\'hex\'))"', + ); + } + + // ── ENCRYPTION_KEY ────────────────────────────────────────────────────────── + // getEncryptionKey() in utils/encryption.ts already throws at call-time when + // this is missing, but catching it at startup is safer — the error surfaces + // before any request is served rather than mid-flight on the first encrypt/ + // decrypt call. + const encryptionKey = process.env.ENCRYPTION_KEY; + + if (!encryptionKey) { + errors.push( + 'ENCRYPTION_KEY is not set. Generate a secure value with:\n' + + ' node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"', + ); + } + + // ── Fail fast ─────────────────────────────────────────────────────────────── + if (errors.length === 0) { + return; + } + + console.error(''); + console.error('╔══════════════════════════════════════════════════════════╗'); + console.error('║ STARTUP FAILED — missing or insecure required secrets ║'); + console.error('╚══════════════════════════════════════════════════════════╝'); + console.error(''); + for (const msg of errors) { + console.error(` ✖ ${msg}`); + console.error(''); + } + + process.exit(1); +}