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

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