fix(security): reject placeholder/weak JWT secrets at startup

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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 01:20:30 +07:00
parent 05651ba4c3
commit e89cd0ce84
4 changed files with 226 additions and 5 deletions

View File

@@ -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=<generate with: openssl rand -base64 48>
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=CHANGE_ME
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------

View File

@@ -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();
});
});

View File

@@ -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<string, string> = 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;
}

View File

@@ -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';