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
6 changes: 6 additions & 0 deletions config/defaults/production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const config = {
app: {
title: 'Devkit Node - Production Environment',
},
// Secure-by-default: keep the unauthenticated API docs surface off in production.
// The runtime gate in lib/services/express.js (isProd) already prevents mounting
// docs in any non-dev env; this flag makes the intent explicit at the config layer.
swagger: {
enable: false,
},
api: {
host: '0.0.0.0',
port: 4200,
Expand Down
26 changes: 25 additions & 1 deletion lib/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,31 @@ const isJwtSecretWeak = (secret) => !secret || secret.trim() === '' || secret.le

/**
* Safe envs where a weak / default secret is tolerated (warn only).
* Also the single source of truth for the dev-vs-prod hardening predicate:
* any NODE_ENV outside this set is treated as a production-grade deployment.
*/
const DEV_ENVS = new Set(['development', 'test', 'local']);

/**
* @desc Predicate — is the given env a known development-grade env?
* Used to gate production hardening. The deployment model runs apps under
* arbitrary NODE_ENV labels, so "dev" is an explicit allow-list (DEV_ENVS),
* never a literal `=== 'development'` check.
* @param {string} [env=process.env.NODE_ENV??'development'] - environment name (read at call time); defaults to 'development' when NODE_ENV is unset
* @returns {boolean} true when env is one of development/test/local
*/
const isDevEnv = (env = process.env.NODE_ENV ?? 'development') => DEV_ENVS.has(env);

/**
* @desc Predicate — should production hardening apply to the given env?
* Inverse of {@link isDevEnv}: true for `production` AND for any non-dev label
* (e.g. a deployment env name), so hardening is secure-by-default off any
* non-dev env, not just the literal `production`.
* @param {string} [env=process.env.NODE_ENV??'development'] - environment name (read at call time); defaults to 'development' when NODE_ENV is unset
* @returns {boolean} true when env is NOT one of development/test/local
*/
const isProd = (env = process.env.NODE_ENV ?? 'development') => !DEV_ENVS.has(env);

/**
* @desc Validate JWT secret strength.
* - In non-dev/non-test environments: throw (fail-closed) when the secret is
Expand All @@ -102,7 +124,7 @@ const validateJwtSecret = (config) => {

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)) {
if (isDevEnv(env)) {
console.log(chalk.red(message));
return;
}
Expand Down Expand Up @@ -213,6 +235,8 @@ export default {
validateDomainIsSet,
JWT_DEFAULT_SECRETS,
isJwtSecretWeak,
isDevEnv,
isProd,
validateJwtSecret,
initSecureMode,
initGlobalConfigFiles,
Expand Down
7 changes: 6 additions & 1 deletion lib/helpers/responses.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import configHelper from './config.js';

/**
* @desc Function res success
* @param {Object} res - Express response object
Expand Down Expand Up @@ -97,7 +99,10 @@ const error = (res, httpStatus, message, description) => (error = {}) => {
errorCode: getErrorCode(error),
description: getDescription(description, error),
};
if (process.env.NODE_ENV !== 'production') result.error = safeStringify(error);
// Only expose the serialized raw error in dev-grade envs (development/test/local).
// Any other NODE_ENV (production or a deployment env label) gets the generic
// envelope only — prevents internal detail leaks under the downstream run model.
if (!configHelper.isProd()) result.error = safeStringify(error);
res.status(status).json(result);
return result;
};
Expand Down
85 changes: 85 additions & 0 deletions lib/helpers/tests/config.envPredicate.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Module dependencies.
*/
import { describe, test, expect } from '@jest/globals';
import configHelper from '../config.js';

/**
* Unit tests for the environment predicate — isDevEnv / isProd.
*
* These are the shared production-hardening predicate. The deployment model runs
* downstream apps as NODE_ENV={projectName} (any non-dev label), so hardening must
* key off "is this NOT a known dev env" rather than the literal "production".
*/
describe('config helper — environment predicate (isDevEnv / isProd):', () => {
describe('isDevEnv', () => {
test('returns true for development', () => {
expect(configHelper.isDevEnv('development')).toBe(true);
});

test('returns true for test', () => {
expect(configHelper.isDevEnv('test')).toBe(true);
});

test('returns true for local', () => {
expect(configHelper.isDevEnv('local')).toBe(true);
});

test('returns false for production', () => {
expect(configHelper.isDevEnv('production')).toBe(false);
});

test('returns false for an arbitrary project env label', () => {
expect(configHelper.isDevEnv('someproject')).toBe(false);
});
});

describe('isProd', () => {
test('returns true for production', () => {
expect(configHelper.isProd('production')).toBe(true);
});

test('returns true for an arbitrary project env label (downstream deployment model)', () => {
expect(configHelper.isProd('someproject')).toBe(true);
});

test('returns false for development', () => {
expect(configHelper.isProd('development')).toBe(false);
});

test('returns false for test', () => {
expect(configHelper.isProd('test')).toBe(false);
});

test('returns false for local', () => {
expect(configHelper.isProd('local')).toBe(false);
});
});

describe('default argument reads process.env.NODE_ENV at call time', () => {
test('isDevEnv() honors NODE_ENV mutated after import', () => {
const original = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'production';
expect(configHelper.isDevEnv()).toBe(false);
expect(configHelper.isProd()).toBe(true);
process.env.NODE_ENV = 'development';
expect(configHelper.isDevEnv()).toBe(true);
expect(configHelper.isProd()).toBe(false);
} finally {
process.env.NODE_ENV = original;
}
});

test('isProd() defaults to development (dev) when NODE_ENV is unset', () => {
const original = process.env.NODE_ENV;
try {
delete process.env.NODE_ENV;
expect(configHelper.isProd()).toBe(false);
expect(configHelper.isDevEnv()).toBe(true);
} finally {
process.env.NODE_ENV = original;
}
});
});
});
69 changes: 69 additions & 0 deletions lib/helpers/tests/responses.errorLeak.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Module dependencies.
*/
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import responses from '../responses.js';

/**
* Build a minimal Express response double that captures status + json body.
* @returns {{status: Function, json: Function, _status: number, _body: object}}
*/
const buildRes = () => {
const res = {
_status: undefined,
_body: undefined,
status(code) { this._status = code; return this; },
json(body) { this._body = body; return this; },
};
return res;
};

/**
* Unit tests — responses.error must NOT serialize the raw error object into the
* client payload outside of dev/test/local. The deployment model runs apps under
* arbitrary NODE_ENV labels, so the leak gate keys off the dev-env predicate, not
* the literal `production`.
*/
describe('responses.error — error-object leak gating:', () => {
let originalNodeEnv;

beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV;
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});

test('does NOT include serialized error in body under a project (non-dev) env', () => {
process.env.NODE_ENV = 'someproject';
const res = buildRes();
responses.error(res, 500, undefined, undefined)(new Error('secret internal detail'));
expect(res._body).toBeDefined();
expect(res._body.error).toBeUndefined();
// The generic envelope fields stay present.
expect(res._body.type).toBe('error');
expect(res._body.status).toBe(500);
});

test('does NOT include serialized error in body under production', () => {
process.env.NODE_ENV = 'production';
const res = buildRes();
responses.error(res, 500)(new Error('secret internal detail'));
expect(res._body.error).toBeUndefined();
});

test('DOES include serialized error in body under development (debugging aid)', () => {
process.env.NODE_ENV = 'development';
const res = buildRes();
responses.error(res, 500)(new Error('debuggable detail'));
expect(typeof res._body.error).toBe('string');
});

test('DOES include serialized error in body under test', () => {
process.env.NODE_ENV = 'test';
const res = buildRes();
responses.error(res, 500)(new Error('debuggable detail'));
expect(typeof res._body.error).toBe('string');
});
});
53 changes: 53 additions & 0 deletions lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Module dependencies.
*/
import { describe, test, expect } from '@jest/globals';
import organizationsDevConfig from '../../../modules/organizations/config/organizations.development.config.js';
import billingDevConfig from '../../../modules/billing/config/billing.development.config.js';
import authDevConfig from '../../../modules/auth/config/auth.development.config.js';

/**
* Config-layering regression guard for the rate-limiter env-gate defect.
*
* The rate-limiter middleware is presence-driven: `limiters.<name>` returns a real
* limiter iff `config.rateLimit.<name>` exists in merged config, else a no-op.
* Previously `rateLimit.api` and `rateLimit.billingPlans` existed ONLY in
* `config/defaults/production.config.js` (literal `NODE_ENV=production`) and
* `test.config.js`, so under the downstream run model (NODE_ENV=<project>) they
* were undefined → no-op → the public Stripe-fanout and membership-request routes
* ran unthrottled.
*
* Fix: each profile lives in its owning module's *.development.config.js — a base
* (Layer 1) layer that ALWAYS merges regardless of NODE_ENV, mirroring how the
* `auth` profile is provided. Stricter caps stay as production-config overrides.
*
* A base-layer profile is a structural contract; assert its presence + shape so a
* future refactor that drops it (re-opening the defect) fails loudly.
*/
describe('rate-limiter base-layer profiles (env-gate config-layering):', () => {
/**
* Assert a profile is a usable express-rate-limit options object.
* @param {object} profile - a config.rateLimit.<name> profile
* @returns {void}
*/
const expectUsableProfile = (profile) => {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
expect(profile).toBeDefined();
expect(typeof profile).toBe('object');
expect(Number.isInteger(profile.windowMs)).toBe(true);
expect(profile.windowMs).toBeGreaterThan(0);
expect(Number.isInteger(profile.max)).toBe(true);
expect(profile.max).toBeGreaterThan(0);
};

test('auth profile already lives in the auth base layer (reference pattern)', () => {
expectUsableProfile(authDevConfig.rateLimit.auth);
});

test('api profile lives in the organizations base layer (always merges)', () => {
expectUsableProfile(organizationsDevConfig.rateLimit.api);
});

test('billingPlans profile lives in the billing base layer (always merges)', () => {
expectUsableProfile(billingDevConfig.rateLimit.billingPlans);
});
});
20 changes: 14 additions & 6 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import YAML from 'js-yaml';
import redoc from 'redoc-express';

import config from '../../config/index.js';
import configHelper from '../helpers/config.js';
import guidesHelper from '../helpers/guides.js';
import redactUrl from '../helpers/redactUrl.js';
import logger from './logger.js';
Expand Down Expand Up @@ -75,7 +76,11 @@ const redocCustomCss = `
* @returns {void}
*/
const initSwagger = (app) => {
if (config.swagger.enable) {
// Secure-by-default: the API docs surface (/api/spec.json + /api/docs) is
// UNAUTHENTICATED, so it is only mounted in dev-grade envs. Any production-grade
// env (the literal `production` OR a deployment env label) skips it even when
// config.swagger.enable is still truthy — opt-OUT by default in non-dev.
if (config.swagger.enable && !configHelper.isProd()) {
if (!config.files.swagger || config.files.swagger.length === 0) {
logger.warn('[swagger] no swagger files configured — skipping API docs');
return;
Expand Down Expand Up @@ -332,8 +337,11 @@ const initModulesServerRoutes = async (app) => {
const initErrorRoutes = (app) => {
/**
* Express 4-arity error handler.
* In production: returns a generic 500 to prevent leaking internal error details (#3726).
* In non-production: includes err.message and err.code for debugging.
* In production-grade envs: returns a generic 500 to prevent leaking internal
* error details (#3726). In dev-grade envs (development/test/local): includes
* err.message and err.code for debugging. "Production-grade" is any env outside
* the dev allow-list, covering the downstream run model where apps boot under a
* deployment env label rather than the literal `production`.
* @param {Error} err - The error object caught by Express
* @param {import('express').Request} req - Express request object
* @param {import('express').Response} res - Express response object
Expand All @@ -343,9 +351,9 @@ const initErrorRoutes = (app) => {
app.use((err, req, res, next) => {
if (!err) return next();
logger.error('Unhandled express error', { error: err });
// In production never leak internal error details (message, code) to clients.
// Detailed errors are still logged above for observability.
if (process.env.NODE_ENV === 'production') {
// In any production-grade env never leak internal error details (message, code)
// to clients. Detailed errors are still logged above for observability.
if (configHelper.isProd()) {
return res.status(err.status || 500).send({ message: 'Internal Server Error' });
}
res.status(err.status || 500).send({
Expand Down
15 changes: 14 additions & 1 deletion lib/services/mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@ import chalk from 'chalk';
import mongoose from 'mongoose';
import path from 'path';
import config from '../../config/index.js';
import configHelper from '../helpers/config.js';
import logger from './logger.js';

/**
* @desc Resolve the effective mongoose `debug` flag.
* Query logging is enabled only when BOTH the config opt-in is set AND the env is
* dev-grade (development/test/local). Any production-grade env (the literal
* `production` OR a deployment env label) forces it off so verbose query logs —
* which can include collection/field/value detail — never run in production.
* @param {object} [cfg=config] - application configuration object
* @returns {boolean} effective debug flag
*/
const resolveDebug = (cfg = config) => Boolean(cfg?.db?.debug) && configHelper.isDevEnv();

/**
* Load all mongoose related models
*/
Expand Down Expand Up @@ -38,7 +50,7 @@ const connect = async () => {
if (mongoOptions.sslKey) mongoOptions.sslKey = path.resolve(mongoOptions.sslKey);

await mongoose.connect(config.db.uri, mongoOptions);
mongoose.set('debug', config.db.debug);
mongoose.set('debug', resolveDebug(config));
logger.info(chalk.yellow('Connected to MongoDB.'));

return mongoose;
Expand All @@ -62,4 +74,5 @@ export default {
loadModels,
connect,
disconnect,
resolveDebug,
};
Loading
Loading