From e89cd0ce842d39a3b11f8387596dc41ad516933c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 01:20:30 +0700 Subject: [PATCH] fix(security): reject placeholder/weak JWT secrets at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env-validation module previously only checked that JWT_SECRET and JWT_REFRESH_SECRET were _present_ — it accepted any value, including known placeholders like "CHANGE_ME". This meant a developer could copy .env.example verbatim and run the app with predictable, forgeable tokens. Changes: - Add FORBIDDEN_SECRET_VALUES blocklist (case-insensitive) with 23 common placeholder strings (CHANGE_ME, secret, password, test, etc.) - Enforce minimum 32-character length for JWT secrets (NIST HMAC guidance) - Export validateJwtSecret() for direct testing and reuse - Update .env.example: replace "CHANGE_ME" with generation instructions - Add 14 unit tests covering placeholder rejection, length enforcement, missing-var errors, and production-mode validation Co-Authored-By: Paperclip --- .env.example | 10 +- .../__tests__/env-validation.spec.ts | 147 ++++++++++++++++++ .../shared/infrastructure/env-validation.ts | 72 ++++++++- .../modules/shared/infrastructure/index.ts | 2 +- 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/shared/infrastructure/__tests__/env-validation.spec.ts diff --git a/.env.example b/.env.example index 16bc8ae..796fe03 100644 --- a/.env.example +++ b/.env.example @@ -54,10 +54,16 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:3001 # ----------------------------------------------------------------------------- # JWT / Auth (REQUIRED — app will not start without these) +# +# SECURITY: Generate strong, random secrets (min 32 characters). +# openssl rand -base64 48 +# +# Do NOT use placeholder values like "CHANGE_ME" — the app will reject them. +# Each secret must be unique and kept out of version control. # ----------------------------------------------------------------------------- -JWT_SECRET=CHANGE_ME +JWT_SECRET= JWT_EXPIRES_IN=15m -JWT_REFRESH_SECRET=CHANGE_ME +JWT_REFRESH_SECRET= JWT_REFRESH_EXPIRES_IN=7d # ----------------------------------------------------------------------------- diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/env-validation.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/env-validation.spec.ts new file mode 100644 index 0000000..13bc2d7 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/env-validation.spec.ts @@ -0,0 +1,147 @@ +import { validateEnv, validateJwtSecret } from '../env-validation'; + +describe('validateJwtSecret', () => { + it('rejects known placeholder values (case-insensitive)', () => { + const placeholders = [ + 'CHANGE_ME', + 'change_me', + 'Change_Me', + 'changeme', + 'your_jwt_secret', + 'your_jwt_secret_change_me', + 'secret', + 'supersecret', + 'password', + 'test', + 'default', + 'placeholder', + 'replace_me', + 'fixme', + 'todo', + 'xxx', + ]; + + for (const value of placeholders) { + const error = validateJwtSecret('JWT_SECRET', value); + expect(error).not.toBeNull(); + expect(error).toContain('placeholder'); + } + }); + + it('rejects secrets shorter than 32 characters', () => { + const error = validateJwtSecret('JWT_SECRET', 'short-but-not-placeholder'); + expect(error).not.toBeNull(); + expect(error).toContain('too short'); + expect(error).toContain('minimum 32'); + }); + + it('accepts valid secrets of sufficient length', () => { + const validSecret = 'a-randomly-generated-secret-that-is-definitely-long-enough-for-production'; + const error = validateJwtSecret('JWT_SECRET', validSecret); + expect(error).toBeNull(); + }); + + it('accepts exactly 32-character secrets', () => { + const error = validateJwtSecret('JWT_SECRET', 'abcdefghijklmnopqrstuvwxyz012345'); + expect(error).toBeNull(); + }); + + it('includes the key name in error messages', () => { + const error = validateJwtSecret('JWT_REFRESH_SECRET', 'CHANGE_ME'); + expect(error).toContain('JWT_REFRESH_SECRET'); + }); +}); + +describe('validateEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + const VALID_SECRET = 'a-valid-random-secret-with-at-least-32-characters-for-tests'; + const VALID_REFRESH_SECRET = 'another-valid-random-secret-at-least-32-characters-long!!'; + + function setValidJwtSecrets() { + process.env['JWT_SECRET'] = VALID_SECRET; + process.env['JWT_REFRESH_SECRET'] = VALID_REFRESH_SECRET; + } + + it('throws when JWT_SECRET is missing', () => { + delete process.env['JWT_SECRET']; + process.env['JWT_REFRESH_SECRET'] = VALID_REFRESH_SECRET; + + expect(() => validateEnv()).toThrow('Missing required environment variables'); + expect(() => validateEnv()).toThrow('JWT_SECRET'); + }); + + it('throws when JWT_REFRESH_SECRET is missing', () => { + process.env['JWT_SECRET'] = VALID_SECRET; + delete process.env['JWT_REFRESH_SECRET']; + + expect(() => validateEnv()).toThrow('Missing required environment variables'); + expect(() => validateEnv()).toThrow('JWT_REFRESH_SECRET'); + }); + + it('throws when both JWT secrets are missing', () => { + delete process.env['JWT_SECRET']; + delete process.env['JWT_REFRESH_SECRET']; + + expect(() => validateEnv()).toThrow('Missing required environment variables'); + }); + + it('throws when JWT_SECRET is a placeholder value', () => { + process.env['JWT_SECRET'] = 'CHANGE_ME'; + process.env['JWT_REFRESH_SECRET'] = VALID_REFRESH_SECRET; + + expect(() => validateEnv()).toThrow('Insecure JWT secret configuration'); + expect(() => validateEnv()).toThrow('placeholder'); + }); + + it('throws when JWT_SECRET is too short', () => { + process.env['JWT_SECRET'] = 'short'; + process.env['JWT_REFRESH_SECRET'] = VALID_REFRESH_SECRET; + + expect(() => validateEnv()).toThrow('Insecure JWT secret configuration'); + expect(() => validateEnv()).toThrow('too short'); + }); + + it('throws when JWT_REFRESH_SECRET is a placeholder value', () => { + process.env['JWT_SECRET'] = VALID_SECRET; + process.env['JWT_REFRESH_SECRET'] = 'CHANGE_ME'; + + expect(() => validateEnv()).toThrow('Insecure JWT secret configuration'); + }); + + it('does not throw with valid secrets in development', () => { + process.env['NODE_ENV'] = 'development'; + setValidJwtSecrets(); + + expect(() => validateEnv()).not.toThrow(); + }); + + it('throws in production when DATABASE_URL is missing', () => { + process.env['NODE_ENV'] = 'production'; + setValidJwtSecrets(); + delete process.env['DATABASE_URL']; + process.env['CORS_ORIGINS'] = 'https://goodgo.vn'; + process.env['REDIS_HOST'] = 'redis.internal'; + + expect(() => validateEnv()).toThrow('Missing required environment variables in production'); + expect(() => validateEnv()).toThrow('DATABASE_URL'); + }); + + it('passes in production with all required vars set', () => { + process.env['NODE_ENV'] = 'production'; + setValidJwtSecrets(); + process.env['DATABASE_URL'] = 'postgresql://localhost/goodgo'; + process.env['CORS_ORIGINS'] = 'https://goodgo.vn'; + process.env['REDIS_HOST'] = 'redis.internal'; + + expect(() => validateEnv()).not.toThrow(); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/env-validation.ts b/apps/api/src/modules/shared/infrastructure/env-validation.ts index 75b5dfb..1f137fd 100644 --- a/apps/api/src/modules/shared/infrastructure/env-validation.ts +++ b/apps/api/src/modules/shared/infrastructure/env-validation.ts @@ -1,8 +1,9 @@ /** * Validates that critical environment variables are set at application startup. * - * Security-sensitive vars (JWT secrets) are required in ALL environments. - * Infrastructure vars (DATABASE_URL, CORS, Redis) are required only in production. + * Security-sensitive vars (JWT secrets) are required in ALL environments and must + * not be placeholder/weak values. Infrastructure vars (DATABASE_URL, CORS, Redis) + * are required only in production. */ const ALWAYS_REQUIRED: readonly string[] = [ @@ -33,6 +34,56 @@ const REQUIRED_WHEN_USED: ReadonlyMap = new Map([ ['ZALO_APP_SECRET', 'Zalo OAuth'], ]); +/** + * Known placeholder values that must never be used as real secrets. + * Comparison is case-insensitive to catch common variants. + */ +const FORBIDDEN_SECRET_VALUES: readonly string[] = [ + 'change_me', + 'changeme', + 'your_jwt_secret', + 'your_jwt_secret_change_me', + 'your_jwt_refresh_secret', + 'your_jwt_refresh_secret_change_me', + 'secret', + 'jwt_secret', + 'jwt_refresh_secret', + 'supersecret', + 'super_secret', + 'mysecret', + 'my_secret', + 'password', + 'test', + 'default', + 'placeholder', + 'replace_me', + 'replaceme', + 'fixme', + 'todo', + 'xxx', +]; + +/** Minimum length for JWT secrets — NIST SP 800-117 recommends ≥256 bits for HMAC keys. */ +const MIN_SECRET_LENGTH = 32; + +/** + * Validates that a JWT secret is not a placeholder value, is long enough, and + * does not appear to be a trivially weak key. + * + * @returns An error message string if the value is invalid, or `null` if OK. + */ +export function validateJwtSecret(key: string, value: string): string | null { + if (FORBIDDEN_SECRET_VALUES.includes(value.toLowerCase().trim())) { + return `${key} is set to a known placeholder value ("${value}"). Generate a real secret: \`openssl rand -base64 48\``; + } + + if (value.length < MIN_SECRET_LENGTH) { + return `${key} is too short (${value.length} chars, minimum ${MIN_SECRET_LENGTH}). Generate a strong secret: \`openssl rand -base64 48\``; + } + + return null; +} + export function validateEnv(): void { const isProduction = process.env['NODE_ENV'] === 'production'; const missing: string[] = []; @@ -52,6 +103,23 @@ export function validateEnv(): void { ); } + // Reject placeholder / weak JWT secret values — these are dangerous even in + // development because tokens signed with predictable keys are trivially forgeable. + const secretErrors: string[] = []; + for (const key of ALWAYS_REQUIRED) { + const error = validateJwtSecret(key, process.env[key]!); + if (error) { + secretErrors.push(error); + } + } + + if (secretErrors.length > 0) { + throw new Error( + `Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` + + 'Generate secure secrets with: openssl rand -base64 48', + ); + } + if (!isProduction) { return; } diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index fb5203e..851962a 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -11,4 +11,4 @@ export { maskPii } from './pii-masker'; export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard'; export { FileValidationPipe } from './pipes/file-validation.pipe'; export type { FileValidationOptions } from './pipes/file-validation.pipe'; -export { validateEnv } from './env-validation'; +export { validateEnv, validateJwtSecret } from './env-validation';