/** * Validates that critical environment variables are set at application startup. * * 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[] = [ 'JWT_SECRET', 'JWT_REFRESH_SECRET', ]; const REQUIRED_IN_PRODUCTION: readonly string[] = [ 'DATABASE_URL', 'CORS_ORIGINS', 'REDIS_HOST', 'FIELD_ENCRYPTION_KEY', ]; const REQUIRED_WHEN_USED: ReadonlyMap = new Map([ ['VNPAY_TMN_CODE', 'VNPay payments'], ['VNPAY_HASH_SECRET', 'VNPay payments'], ['MOMO_PARTNER_CODE', 'MoMo payments'], ['MOMO_ACCESS_KEY', 'MoMo payments'], ['MOMO_SECRET_KEY', 'MoMo payments'], ['ZALOPAY_APP_ID', 'ZaloPay payments'], ['ZALOPAY_KEY1', 'ZaloPay payments'], ['ZALOPAY_KEY2', 'ZaloPay payments'], ['BANK_TRANSFER_ACCOUNT_NUMBER', 'Bank transfer payments'], ['BANK_TRANSFER_BANK_NAME', 'Bank transfer payments'], ['BANK_TRANSFER_ACCOUNT_HOLDER', 'Bank transfer payments'], ['BANK_TRANSFER_WEBHOOK_SECRET', 'Bank transfer payments'], ['MINIO_ACCESS_KEY', 'Media storage'], ['MINIO_SECRET_KEY', 'Media storage'], ['GOOGLE_CLIENT_ID', 'Google OAuth'], ['GOOGLE_CLIENT_SECRET', 'Google OAuth'], ['ZALO_APP_ID', 'Zalo OAuth'], ['ZALO_APP_SECRET', 'Zalo OAuth'], ['ZALO_OA_ID', 'Zalo OA notifications'], ['ZALO_OA_ACCESS_TOKEN', 'Zalo OA notifications'], ]); /** * Known placeholder values that must never be used as real secrets. * Comparison is case-insensitive to catch common variants. */ /** * Previous-version secrets used during key rotation. Validated if set but never * required. Note: JWT_REFRESH_SECRET_PREVIOUS currently has no runtime consumer * because refresh tokens are opaque random bytes, not JWTs — the variable is * accepted here for forward-compatibility should the refresh mechanism change. */ const OPTIONAL_PREVIOUS_SECRETS: readonly string[] = [ 'JWT_SECRET_PREVIOUS', 'JWT_REFRESH_SECRET_PREVIOUS', ]; 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[] = []; // JWT secrets are required in every environment — a missing secret is a // security risk regardless of NODE_ENV. for (const key of ALWAYS_REQUIRED) { if (!process.env[key]) { missing.push(key); } } if (missing.length > 0) { throw new Error( `Missing required environment variables:\n ${missing.join('\n ')}\n` + 'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.', ); } // 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', ); } // Validate optional previous secrets if they are set (rotation window). const prevSecretErrors: string[] = []; for (const key of OPTIONAL_PREVIOUS_SECRETS) { const value = process.env[key]; if (value) { const error = validateJwtSecret(key, value); if (error) { prevSecretErrors.push(error); } } } if (prevSecretErrors.length > 0) { throw new Error( `Insecure previous-secret configuration:\n ${prevSecretErrors.join('\n ')}\n` + 'Previous secrets must meet the same strength requirements as primary secrets.', ); } if (!isProduction) { return; } // Infrastructure vars — required in production only. const missingProd: string[] = []; for (const key of REQUIRED_IN_PRODUCTION) { if (!process.env[key]) { missingProd.push(key); } } if (missingProd.length > 0) { throw new Error( `Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` + 'See .env.example for the full list of variables.', ); } // Warn about payment/storage vars that are empty — services will fail at // runtime when invoked, but we surface the warning early. const warnings: string[] = []; for (const [key, feature] of REQUIRED_WHEN_USED) { if (!process.env[key]) { warnings.push(` ${key} (${feature})`); } } if (warnings.length > 0) { console.warn( `[env-validation] The following optional vars are unset — related features will fail at runtime:\n${warnings.join('\n')}`, ); } }