From 7d3e23af168277f10f2a3883153800804779eaab Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 5 Jun 2026 10:57:35 +0200 Subject: [PATCH] refactor(config): export isJwtSecretWeak + JWT_DEFAULT_SECRETS as single source of truth Deduplicates the JWT weakness predicate that existed independently in config.js (inline in validateJwtSecret) and home.service.js (private JWT_DEFAULTS Set + jwtInsecure expression). Both call sites now use the single exported helper; zero behavior change. JWT_DEFAULT_SECRETS is frozen to prevent accidental mutation of the canonical set. Closes #3792 --- lib/helpers/config.js | 18 +++- .../config.isJwtSecretWeak.unit.tests.js | 94 +++++++++++++++++++ modules/home/services/home.service.js | 18 +--- modules/home/tests/home.service.unit.tests.js | 25 +++++ 4 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 lib/helpers/tests/config.isJwtSecretWeak.unit.tests.js diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 8d4def32b..86d20dbc8 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -58,15 +58,25 @@ const validateDomainIsSet = (config) => { * 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. + * @readonly */ -const JWT_DEFAULT_SECRETS = new Set([ +const JWT_DEFAULT_SECRETS = Object.freeze(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 -]); +])); + +/** + * @desc Canonical JWT weakness predicate — single source of truth. + * Returns true when the secret is empty, whitespace-only, shorter than 32 + * characters, or matches a known default placeholder. + * @param {string|null|undefined} secret - raw JWT secret value + * @returns {boolean} + */ +const isJwtSecretWeak = (secret) => !secret || secret.trim() === '' || secret.length < 32 || JWT_DEFAULT_SECRETS.has(secret); /** * Safe envs where a weak / default secret is tolerated (warn only). @@ -86,7 +96,7 @@ const validateJwtSecret = (config) => { 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); + const isWeak = isJwtSecretWeak(secret); if (!isWeak) return; // strong secret — nothing to do @@ -201,6 +211,8 @@ const initGlobalConfigFiles = async (assets) => { export default { getGlobbedPaths, validateDomainIsSet, + JWT_DEFAULT_SECRETS, + isJwtSecretWeak, validateJwtSecret, initSecureMode, initGlobalConfigFiles, diff --git a/lib/helpers/tests/config.isJwtSecretWeak.unit.tests.js b/lib/helpers/tests/config.isJwtSecretWeak.unit.tests.js new file mode 100644 index 000000000..ba0aaeade --- /dev/null +++ b/lib/helpers/tests/config.isJwtSecretWeak.unit.tests.js @@ -0,0 +1,94 @@ +/** + * Unit tests for isJwtSecretWeak in config helper. + * + * Verifies that the exported helper returns the same verdicts as the two + * former private predicates it replaces (validateJwtSecret + jwtInsecure in + * home.service.js). Zero behavior change is the acceptance criterion. + * + * Weak cases (returns true): + * - undefined / null / empty string / whitespace-only + * - length < 32 characters + * - each known default placeholder (devkit + all downstream) + * + * Strong case (returns false): + * - a ≥ 32-char string that is not in the defaults list + */ +import { describe, test, expect } from '@jest/globals'; +import configHelper from '../config.js'; + +const { isJwtSecretWeak, JWT_DEFAULT_SECRETS } = configHelper; + +const STRONG_SECRET = 'a'.repeat(32); // exactly 32 chars, non-default + +describe('config.isJwtSecretWeak', () => { + // ---- weak: empty / missing ------------------------------------------------ + + test('undefined → true (weak)', () => { + expect(isJwtSecretWeak(undefined)).toBe(true); + }); + + test('null → true (weak)', () => { + expect(isJwtSecretWeak(null)).toBe(true); + }); + + test('empty string → true (weak)', () => { + expect(isJwtSecretWeak('')).toBe(true); + }); + + test('whitespace-only string → true (weak)', () => { + expect(isJwtSecretWeak(' ')).toBe(true); + }); + + // ---- weak: too short ------------------------------------------------------ + + test('short secret (< 32 chars, not a known default) → true (weak)', () => { + expect(isJwtSecretWeak('tooshort')).toBe(true); + }); + + test('31-char secret → true (weak)', () => { + expect(isJwtSecretWeak('a'.repeat(31))).toBe(true); + }); + + // ---- weak: each known default placeholder --------------------------------- + + test('devkit placeholder → true (weak)', () => { + expect(isJwtSecretWeak('WaosSecretKeyExampleToChnageAbsolutely')).toBe(true); + }); + + test('TrawlNodeDevSecret → true (weak)', () => { + expect(isJwtSecretWeak('TrawlNodeDevSecret')).toBe(true); + }); + + test('ComesNodeDevSecret → true (weak)', () => { + expect(isJwtSecretWeak('ComesNodeDevSecret')).toBe(true); + }); + + test('MontaineNodeDevSecret → true (weak)', () => { + expect(isJwtSecretWeak('MontaineNodeDevSecret')).toBe(true); + }); + + test('PierrebNodeDevSecret → true (weak)', () => { + expect(isJwtSecretWeak('PierrebNodeDevSecret')).toBe(true); + }); + + test('IsmNodeDevSecret → true (weak)', () => { + expect(isJwtSecretWeak('IsmNodeDevSecret')).toBe(true); + }); + + // covers the full JWT_DEFAULT_SECRETS set exhaustively + test('all entries in JWT_DEFAULT_SECRETS → true (weak)', () => { + for (const s of JWT_DEFAULT_SECRETS) { + expect(isJwtSecretWeak(s)).toBe(true); + } + }); + + // ---- strong --------------------------------------------------------------- + + test('32-char non-default secret → false (strong)', () => { + expect(isJwtSecretWeak(STRONG_SECRET)).toBe(false); + }); + + test('long strong secret → false (strong)', () => { + expect(isJwtSecretWeak('supersecret_random_string_at_least_32_chars!')).toBe(false); + }); +}); diff --git a/modules/home/services/home.service.js b/modules/home/services/home.service.js index 0b8413182..58514b5db 100644 --- a/modules/home/services/home.service.js +++ b/modules/home/services/home.service.js @@ -7,9 +7,12 @@ import { promises as fs } from 'fs'; import mongoose from 'mongoose'; import config from '../../../config/index.js'; +import configHelper from '../../../lib/helpers/config.js'; import mailer from '../../../lib/helpers/mailer/index.js'; import HomeRepository from '../repositories/home.repository.js'; +const { isJwtSecretWeak } = configHelper; + /** * @desc Check whether a config value is meaningfully set (non-empty, not a DEVKIT placeholder). * @param {*} value - Config value to check @@ -73,19 +76,8 @@ const getReadinessStatus = () => { message: domainSet ? 'Domain configured' : 'Domain not configured', }); - // 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.length < 32 || JWT_DEFAULTS.has(jwtSecret); + // security — JWT secret (uses shared isJwtSecretWeak from config helper) + const jwtInsecure = isJwtSecretWeak(config.jwt?.secret); checks.push({ category: 'security', status: jwtInsecure ? 'warning' : 'ok', diff --git a/modules/home/tests/home.service.unit.tests.js b/modules/home/tests/home.service.unit.tests.js index f1f7328d9..8f056330d 100644 --- a/modules/home/tests/home.service.unit.tests.js +++ b/modules/home/tests/home.service.unit.tests.js @@ -110,4 +110,29 @@ describe('HomeService.getReadinessStatus unit tests:', () => { expect(row.status).toBe('warning'); }); }); + + describe('security (JWT) row — uses shared isJwtSecretWeak', () => { + test('warning when jwt.secret is a known default placeholder', async () => { + const HomeService = await withConfig({ jwt: { secret: 'WaosSecretKeyExampleToChnageAbsolutely' } }); + const checks = HomeService.getReadinessStatus(); + const row = checks.find((c) => c.category === 'security'); + expect(row.status).toBe('warning'); + expect(row.message).toContain('known default'); + }); + + test('warning when jwt.secret is too short', async () => { + const HomeService = await withConfig({ jwt: { secret: 'tooshort' } }); + const checks = HomeService.getReadinessStatus(); + const row = checks.find((c) => c.category === 'security'); + expect(row.status).toBe('warning'); + }); + + test('ok when jwt.secret is a strong (≥32-char, non-default) secret', async () => { + const HomeService = await withConfig({ jwt: { secret: 'a'.repeat(32) } }); + const checks = HomeService.getReadinessStatus(); + const row = checks.find((c) => c.category === 'security'); + expect(row.status).toBe('ok'); + expect(row.message).toBe('JWT secret is custom'); + }); + }); });