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); +}