Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions lib/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +86 to +91

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}`);
};

/**
Expand Down
126 changes: 126 additions & 0 deletions lib/helpers/tests/config.validateJwtSecret.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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;
});
Comment on lines +34 to +37

// ---- 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();
});
});
14 changes: 12 additions & 2 deletions modules/home/services/home.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Comment on lines +79 to +86
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({
Comment on lines 87 to 89
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
Expand Down
2 changes: 1 addition & 1 deletion modules/home/tests/home.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down
Loading