From d23d314a9f7daf097e7259d0c763ce31f37d3dcc Mon Sep 17 00:00:00 2001 From: MorganOnCode <87934408+MorganOnCode@users.noreply.github.com> Date: Fri, 15 May 2026 10:44:09 +0000 Subject: [PATCH] fix(config): require chain.redis.password when env="production" Closes audit #15. The deployment runbook says Redis MUST be password-protected in production, but the schema marked chain.redis.password as optional() unconditionally. An operator could deploy with config.env="production" and a missing/empty password and the server would happily come up, connecting to an unauthenticated Redis. That's a real production-safety gap. Adds a superRefine on the root ConfigSchema that mirrors the existing MAINNET=true guardrail in chain/config.ts: when config.env is "production", chain.redis.password must be a non-empty, non-whitespace string. Otherwise the config fails validation at startup with a descriptive error pointing to .env and config.json. development and test envs are unaffected -- they can still run without a Redis password (the default dev compose's redis service has no auth). 5 new tests cover: missing password rejected, empty string rejected, whitespace-only rejected, valid password accepted, dev mode unaffected. Full suite: 34 files / 457 tests pass (was 34 / 452). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/schema.ts | 161 +++++++++++++++++++++----------------- tests/unit/config.test.ts | 62 +++++++++++++++ 2 files changed, 153 insertions(+), 70 deletions(-) diff --git a/src/config/schema.ts b/src/config/schema.ts index 79e6924..8540119 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -2,82 +2,103 @@ import { z } from 'zod'; import { ChainConfigSchema } from '../chain/config.js'; -export const ConfigSchema = z.object({ - server: z - .object({ - host: z.string().default('0.0.0.0'), - port: z.number().int().min(1).max(65535).default(3000), - }) - .default(() => ({ host: '0.0.0.0', port: 3000 })), +export const ConfigSchema = z + .object({ + server: z + .object({ + host: z.string().default('0.0.0.0'), + port: z.number().int().min(1).max(65535).default(3000), + }) + .default(() => ({ host: '0.0.0.0', port: 3000 })), - logging: z - .object({ - level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), - pretty: z.boolean().default(false), - }) - .default(() => ({ level: 'info' as const, pretty: false })), + logging: z + .object({ + level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), + pretty: z.boolean().default(false), + }) + .default(() => ({ level: 'info' as const, pretty: false })), - // Optional Sentry integration - sentry: z - .object({ - dsn: z.string().url(), - environment: z.string().default('development'), - tracesSampleRate: z.number().min(0).max(1).default(0.1), - }) - .optional(), + // Optional Sentry integration + sentry: z + .object({ + dsn: z.string().url(), + environment: z.string().default('development'), + tracesSampleRate: z.number().min(0).max(1).default(0.1), + }) + .optional(), - // Environment mode - env: z.enum(['development', 'production', 'test']).default('development'), + // Environment mode + env: z.enum(['development', 'production', 'test']).default('development'), - // Rate limiting configuration - rateLimit: z - .object({ - global: z.number().int().min(1).default(100), - sensitive: z.number().int().min(1).default(20), - windowMs: z.number().int().min(1000).default(60000), - }) - .default(() => ({ global: 100, sensitive: 20, windowMs: 60000 })), + // Rate limiting configuration + rateLimit: z + .object({ + global: z.number().int().min(1).default(100), + sensitive: z.number().int().min(1).default(20), + windowMs: z.number().int().min(1000).default(60000), + }) + .default(() => ({ global: 100, sensitive: 20, windowMs: 60000 })), - // Chain provider configuration (Blockfrost, network, cache, reservation, Redis) - chain: ChainConfigSchema, + // Chain provider configuration (Blockfrost, network, cache, reservation, Redis) + chain: ChainConfigSchema, - // Demo configuration (optional -- separate testnet credentials for the live demo widget) - demo: z - .object({ - /** Blockfrost project ID for demo (typically Preview testnet) */ - blockfrostProjectId: z.string().min(1), - /** Seed phrase for demo wallet (Preview testnet wallet) */ - seedPhrase: z.string().min(1), - /** Network for demo transactions */ - network: z.enum(['Preview', 'Preprod']).default('Preview'), - }) - .optional(), + // Demo configuration (optional -- separate testnet credentials for the live demo widget) + demo: z + .object({ + /** Blockfrost project ID for demo (typically Preview testnet) */ + blockfrostProjectId: z.string().min(1), + /** Seed phrase for demo wallet (Preview testnet wallet) */ + seedPhrase: z.string().min(1), + /** Network for demo transactions */ + network: z.enum(['Preview', 'Preprod']).default('Preview'), + }) + .optional(), - // Storage backend configuration (optional -- defaults to filesystem) - storage: z - .object({ - /** Storage backend type */ - backend: z.enum(['fs', 'ipfs']).default('fs'), - /** Filesystem backend options */ - fs: z - .object({ - /** Directory for stored files (default: ./data/files) */ - dataDir: z.string().default('./data/files'), - }) - .default(() => ({ dataDir: './data/files' })), - /** IPFS backend options */ - ipfs: z - .object({ - /** IPFS Kubo HTTP API URL (default: http://localhost:5001) */ - apiUrl: z.string().url().default('http://localhost:5001'), - }) - .default(() => ({ apiUrl: 'http://localhost:5001' })), - }) - .default(() => ({ - backend: 'fs' as const, - fs: { dataDir: './data/files' }, - ipfs: { apiUrl: 'http://localhost:5001' }, - })), -}); + // Storage backend configuration (optional -- defaults to filesystem) + storage: z + .object({ + /** Storage backend type */ + backend: z.enum(['fs', 'ipfs']).default('fs'), + /** Filesystem backend options */ + fs: z + .object({ + /** Directory for stored files (default: ./data/files) */ + dataDir: z.string().default('./data/files'), + }) + .default(() => ({ dataDir: './data/files' })), + /** IPFS backend options */ + ipfs: z + .object({ + /** IPFS Kubo HTTP API URL (default: http://localhost:5001) */ + apiUrl: z.string().url().default('http://localhost:5001'), + }) + .default(() => ({ apiUrl: 'http://localhost:5001' })), + }) + .default(() => ({ + backend: 'fs' as const, + fs: { dataDir: './data/files' }, + ipfs: { apiUrl: 'http://localhost:5001' }, + })), + }) + .superRefine((data, ctx) => { + // Production-mode redis password guardrail. The docs/vps-deployment.md + // runbook says Redis MUST be password-protected in production, but the + // chain.redis.password schema is `optional()` to support the no-auth + // dev compose. Enforce the production requirement here so an operator + // can't accidentally deploy with config.env="production" and an empty + // Redis password. Mirrors the MAINNET=true guardrail pattern in chain/config.ts. + if (data.env === 'production') { + const pwd = data.chain.redis.password; + if (!pwd || pwd.trim() === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'chain.redis.password is required when config.env is "production". ' + + 'Set REDIS_PASSWORD in .env and chain.redis.password in config.json.', + path: ['chain', 'redis', 'password'], + }); + } + } + }); export type Config = z.infer; diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index e46341f..09e0e5d 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -136,6 +136,68 @@ describe('Config Loading', () => { } }); + describe('Redis password production guardrail', () => { + it('rejects production env with no chain.redis.password', () => { + const cfg = { + env: 'production', + chain: { + ...minimalChainConfig, + redis: { host: 'redis', port: 6379 }, + }, + }; + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(cfg)); + expect(() => loadConfig(TEST_CONFIG_PATH)).toThrowError(/CONFIG_INVALID|password/); + }); + + it('rejects production env with empty chain.redis.password', () => { + const cfg = { + env: 'production', + chain: { + ...minimalChainConfig, + redis: { host: 'redis', port: 6379, password: '' }, + }, + }; + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(cfg)); + expect(() => loadConfig(TEST_CONFIG_PATH)).toThrowError(/CONFIG_INVALID|password/); + }); + + it('rejects production env with whitespace-only chain.redis.password', () => { + const cfg = { + env: 'production', + chain: { + ...minimalChainConfig, + redis: { host: 'redis', port: 6379, password: ' ' }, + }, + }; + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(cfg)); + expect(() => loadConfig(TEST_CONFIG_PATH)).toThrowError(/CONFIG_INVALID|password/); + }); + + it('accepts production env with a non-empty chain.redis.password', () => { + const cfg = { + env: 'production', + chain: { + ...minimalChainConfig, + redis: { host: 'redis', port: 6379, password: 'a-real-password' }, + }, + }; + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(cfg)); + expect(() => loadConfig(TEST_CONFIG_PATH)).not.toThrow(); + }); + + it('accepts development env with no chain.redis.password (dev compose has no auth)', () => { + const cfg = { + env: 'development', + chain: { + ...minimalChainConfig, + redis: { host: 'localhost', port: 6379 }, + }, + }; + writeFileSync(TEST_CONFIG_PATH, JSON.stringify(cfg)); + expect(() => loadConfig(TEST_CONFIG_PATH)).not.toThrow(); + }); + }); + it('should reject mainnet without MAINNET=true env var', () => { const mainnetConfig = { chain: {