diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 4af8ebed1..8d4def32b 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -55,14 +55,50 @@ const validateDomainIsSet = (config) => { }; /** - * @desc Warn if JWT secret is still set to the default placeholder value. + * Known default / placeholder JWT secret values that must never be used in + * non-dev environments. Extend this list when a new downstream project is + * bootstrapped with its own placeholder. + */ +const JWT_DEFAULT_SECRETS = new Set([ + 'WaosSecretKeyExampleToChnageAbsolutely', // devkit upstream placeholder + 'TrawlNodeDevSecret', // trawl downstream placeholder + 'ComesNodeDevSecret', // comes downstream placeholder + 'MontaineNodeDevSecret', // montaine downstream placeholder + 'PierrebNodeDevSecret', // pierreb downstream placeholder + 'IsmNodeDevSecret', // ism downstream placeholder +]); + +/** + * Safe envs where a weak / default secret is tolerated (warn only). + */ +const DEV_ENVS = new Set(['development', 'test', 'local']); + +/** + * @desc Validate JWT secret strength. + * - In non-dev/non-test environments: throw (fail-closed) when the secret is + * empty, shorter than 32 chars, or matches a known default placeholder. + * - In dev/test/local: keep the existing console.log warn so local and CI + * boots still succeed. * @param {object} config - application configuration object * @returns {void} */ const validateJwtSecret = (config) => { - if (config.jwt && config.jwt.secret === 'WaosSecretKeyExampleToChnageAbsolutely') { - console.log(chalk.red('+ Important warning: JWT secret is set to the default value. It should be changed in production via DEVKIT_NODE_jwt_secret.')); + const secret = config.jwt?.secret; + const env = process.env.NODE_ENV ?? 'development'; + + const isWeak = !secret || secret.trim() === '' || secret.length < 32 || JWT_DEFAULT_SECRETS.has(secret); + + if (!isWeak) return; // strong secret — nothing to do + + const message = '+ Important warning: JWT secret is empty, too short (< 32 chars), or set to a known default placeholder. Set a strong secret via DEVKIT_NODE_jwt_secret.'; + + if (DEV_ENVS.has(env)) { + console.log(chalk.red(message)); + return; } + + // Non-dev environments: fail closed — crash loud rather than run insecurely. + throw new Error(`[security] validateJwtSecret: ${message}`); }; /** diff --git a/lib/helpers/tests/config.validateJwtSecret.unit.tests.js b/lib/helpers/tests/config.validateJwtSecret.unit.tests.js new file mode 100644 index 000000000..4fa558420 --- /dev/null +++ b/lib/helpers/tests/config.validateJwtSecret.unit.tests.js @@ -0,0 +1,126 @@ +/** + * Unit tests for validateJwtSecret in config helper. + * + * Behaviour matrix: + * - prod env + empty secret → throws + * - prod env + short secret (<32) → throws + * - prod env + devkit placeholder → throws + * - prod env + downstream default → throws + * - prod env + strong secret (≥32) → no throw, no warn + * - dev env + default secret → console.log warn, no throw + * - test env + default secret → console.log warn, no throw + * - local env + default secret → console.log warn, no throw + */ +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'; + +// We import the module directly (no mocking needed — pure utility). +import configHelper from '../config.js'; + +const { validateJwtSecret } = configHelper; + +const STRONG_SECRET = 'a'.repeat(32); // exactly 32 chars, non-default +const DEVKIT_PLACEHOLDER = 'WaosSecretKeyExampleToChnageAbsolutely'; +const DOWNSTREAM_DEFAULT = 'TrawlNodeDevSecret'; // known downstream placeholder (< 32 chars too) +const SHORT_SECRET = 'tooshort'; // < 32 chars, not a known default + +describe('config.validateJwtSecret', () => { + let consoleLogSpy; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + + // ---- prod: fail-closed ------------------------------------------------ + + test('prod + undefined secret → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: undefined } })).toThrow(); + }); + + test('prod + empty string secret → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: '' } })).toThrow(); + }); + + test('prod + whitespace-only secret → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: ' ' } })).toThrow(); + }); + + test('prod + short secret (< 32 chars) → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: SHORT_SECRET } })).toThrow(); + }); + + test('prod + devkit placeholder → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: DEVKIT_PLACEHOLDER } })).toThrow(); + }); + + test('prod + downstream default (TrawlNodeDevSecret) → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: DOWNSTREAM_DEFAULT } })).toThrow(); + }); + + test('prod + no jwt key at all → throws', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({})).toThrow(); + }); + + test('prod + strong secret (≥32 chars, non-default) → no throw, no warn', () => { + process.env.NODE_ENV = 'production'; + expect(() => validateJwtSecret({ jwt: { secret: STRONG_SECRET } })).not.toThrow(); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + // ---- staging / other non-dev: also fail-closed ----------------------- + + test('staging env + short secret → throws', () => { + process.env.NODE_ENV = 'staging'; + expect(() => validateJwtSecret({ jwt: { secret: SHORT_SECRET } })).toThrow(); + }); + + // ---- dev/test/local: warn, never throw -------------------------------- + + test('dev env + devkit placeholder → warns (console.log), no throw', () => { + process.env.NODE_ENV = 'development'; + expect(() => validateJwtSecret({ jwt: { secret: DEVKIT_PLACEHOLDER } })).not.toThrow(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test('dev env + empty secret → warns (console.log), no throw', () => { + process.env.NODE_ENV = 'development'; + expect(() => validateJwtSecret({ jwt: { secret: '' } })).not.toThrow(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test('dev env + short secret → warns (console.log), no throw', () => { + process.env.NODE_ENV = 'development'; + expect(() => validateJwtSecret({ jwt: { secret: SHORT_SECRET } })).not.toThrow(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test('test env + devkit placeholder → warns (console.log), no throw', () => { + process.env.NODE_ENV = 'test'; + expect(() => validateJwtSecret({ jwt: { secret: DEVKIT_PLACEHOLDER } })).not.toThrow(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test('local env + devkit placeholder → warns (console.log), no throw', () => { + process.env.NODE_ENV = 'local'; + expect(() => validateJwtSecret({ jwt: { secret: DEVKIT_PLACEHOLDER } })).not.toThrow(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test('dev env + strong secret → no throw, no warn', () => { + process.env.NODE_ENV = 'development'; + expect(() => validateJwtSecret({ jwt: { secret: STRONG_SECRET } })).not.toThrow(); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/home/services/home.service.js b/modules/home/services/home.service.js index e3f2fb018..0b8413182 100644 --- a/modules/home/services/home.service.js +++ b/modules/home/services/home.service.js @@ -74,12 +74,22 @@ const getReadinessStatus = () => { }); // security — JWT secret + // Re-use the same weakness predicate as validateJwtSecret (config helper): + // empty / whitespace / < 32 chars / known default placeholder. + const JWT_DEFAULTS = new Set([ + 'WaosSecretKeyExampleToChnageAbsolutely', + 'TrawlNodeDevSecret', + 'ComesNodeDevSecret', + 'MontaineNodeDevSecret', + 'PierrebNodeDevSecret', + 'IsmNodeDevSecret', + ]); const jwtSecret = config.jwt?.secret; - const jwtInsecure = !jwtSecret || jwtSecret.trim() === '' || jwtSecret === 'WaosSecretKeyExampleToChnageAbsolutely'; + const jwtInsecure = !jwtSecret || jwtSecret.trim() === '' || jwtSecret.length < 32 || JWT_DEFAULTS.has(jwtSecret); checks.push({ category: 'security', status: jwtInsecure ? 'warning' : 'ok', - message: jwtInsecure ? 'JWT secret is missing or default — change it before production' : 'JWT secret is custom', + message: jwtInsecure ? 'JWT secret is missing, too short (< 32 chars), or a known default — change it before production' : 'JWT secret is custom', }); // auth — OAuth providers diff --git a/modules/home/tests/home.integration.tests.js b/modules/home/tests/home.integration.tests.js index c89e4a12e..6fd5c110d 100644 --- a/modules/home/tests/home.integration.tests.js +++ b/modules/home/tests/home.integration.tests.js @@ -190,7 +190,7 @@ describe('Home integration tests:', () => { const mailerSpy = jest.spyOn(mailer, 'isConfigured').mockReturnValue(true); try { config.domain = 'example.com'; - config.jwt.secret = 'a-real-custom-secret-key'; + config.jwt.secret = 'a-real-custom-secret-key-well-over-32-characters-long'; config.oAuth = { google: { clientID: 'google-id' }, apple: { clientID: 'apple-id' } }; config.stripe = { secretKey: 'sk_test_123' }; config.analytics = { posthog: { key: 'phk_123', errorTracking: true } };