From 4ee2fe47b27439151afc789d6b9846fe16454f73 Mon Sep 17 00:00:00 2001 From: Olamiposi Date: Mon, 1 Jun 2026 20:45:18 +0000 Subject: [PATCH] Normalize API environment variables with strict validation and secret-aware logging --- api/.env.example | 2 +- api/src/__tests__/config.test.ts | 32 ++++ api/src/config/index.ts | 25 +++ api/src/config/validators.ts | 81 +++++++- package-lock.json | 313 ------------------------------- 5 files changed, 138 insertions(+), 315 deletions(-) diff --git a/api/.env.example b/api/.env.example index 63b134c..062afc5 100644 --- a/api/.env.example +++ b/api/.env.example @@ -80,7 +80,7 @@ JWT_EXPIRES_IN=24h # Comma-separated list of allowed frontend origins. # In development this defaults to '*' (all origins) when not set. -# In production this defaults to restrictive (no origins) — set it explicitly. +# In production this is required and must not include '*'. # Example: https://app.stellarlend.io,https://staging.stellarlend.io # [OPTIONAL in development, REQUIRED in production] ALLOWED_ORIGINS= diff --git a/api/src/__tests__/config.test.ts b/api/src/__tests__/config.test.ts index cd48572..d3b5cfe 100644 --- a/api/src/__tests__/config.test.ts +++ b/api/src/__tests__/config.test.ts @@ -53,4 +53,36 @@ describe('Config Validation', () => { require('../config/index'); }).toThrow('Config validation failed: JWT_SECRET must be at least 32 characters'); }); + + it('should require ALLOWED_ORIGINS in production', () => { + process.env.CONTRACT_ID = 'TEST_CONTRACT_ID'; + process.env.JWT_SECRET = 'a-secure-secret-that-is-at-least-thirty-two-characters-long'; + process.env.NODE_ENV = 'production'; + delete process.env.ALLOWED_ORIGINS; + + expect(() => { + require('../config/index'); + }).toThrow('Config validation failed: ALLOWED_ORIGINS is required in production'); + }); + + it('should require REDIS_URL when REDIS_ENABLED is true', () => { + process.env.CONTRACT_ID = 'TEST_CONTRACT_ID'; + process.env.JWT_SECRET = 'a-secure-secret-that-is-at-least-thirty-two-characters-long'; + process.env.REDIS_ENABLED = 'true'; + delete process.env.REDIS_URL; + + expect(() => { + require('../config/index'); + }).toThrow('Config validation failed: REDIS_URL is required when REDIS_ENABLED=true'); + }); + + it('should reject invalid URL values', () => { + process.env.CONTRACT_ID = 'TEST_CONTRACT_ID'; + process.env.JWT_SECRET = 'a-secure-secret-that-is-at-least-thirty-two-characters-long'; + process.env.HORIZON_URL = 'not-a-url'; + + expect(() => { + require('../config/index'); + }).toThrow('Config validation failed: HORIZON_URL must be a valid URL'); + }); }); diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 8df6f0d..d359c41 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -10,6 +10,21 @@ dotenv.config(); let configSource = 'environment'; +const SENSITIVE_ENV_PATTERN = /secret|token|password|api[_-]?key|key/i; + +function maskSecret(value: string | undefined): string { + if (!value) return '****'; + if (value.length <= 8) return '****'; + return `${value.slice(0, 2)}${'*'.repeat(value.length - 4)}${value.slice(-2)}`; +} + +function getSensitiveEnvAuditEntries(): string[] { + return Object.entries(process.env) + .filter(([key]) => SENSITIVE_ENV_PATTERN.test(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${maskSecret(value)}`); +} + function parseCorsOrigins(): string[] { const raw = process.env.ALLOWED_ORIGINS; if (raw) { @@ -172,6 +187,16 @@ function buildConfig(): AppConfig { assertValidConfig(cfg); + const sensitiveEntries = getSensitiveEnvAuditEntries(); + if (sensitiveEntries.length > 0) { + configAuditService.record({ + timestamp: new Date().toISOString(), + action: 'validated', + source: configSource, + changes: sensitiveEntries, + }); + } + return cfg; } diff --git a/api/src/config/validators.ts b/api/src/config/validators.ts index 1e5bdcf..934fcc5 100644 --- a/api/src/config/validators.ts +++ b/api/src/config/validators.ts @@ -1,10 +1,49 @@ import { AppConfig } from './types'; import { ValidationError } from '../utils/errors'; +import { configAuditService } from '../services/configAudit.service'; + +const VALID_ENVS = ['development', 'staging', 'production'] as const; +const VALID_NETWORKS = ['testnet', 'mainnet'] as const; +const VALID_LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const; + +function isNonEmptyString(value: string): boolean { + return Boolean(value?.trim()); +} + +function isValidUrl(value: string | undefined): boolean { + if (!value) return false; + try { + new URL(value); + return true; + } catch { + return false; + } +} export function validateConfig(config: AppConfig): string[] { const errors: string[] = []; - if (!config.stellar.contractId) { + if (!VALID_ENVS.includes(config.server.env as any)) { + errors.push('NODE_ENV must be one of development, staging, production'); + } + + if (!VALID_NETWORKS.includes(config.stellar.network as any)) { + errors.push('STELLAR_NETWORK must be testnet or mainnet'); + } + + if (!isValidUrl(config.stellar.horizonUrl)) { + errors.push('HORIZON_URL must be a valid URL'); + } + + if (!isValidUrl(config.stellar.sorobanRpcUrl)) { + errors.push('SOROBAN_RPC_URL must be a valid URL'); + } + + if (!isNonEmptyString(config.stellar.networkPassphrase)) { + errors.push('NETWORK_PASSPHRASE is required'); + } + + if (!isNonEmptyString(config.stellar.contractId)) { errors.push('CONTRACT_ID is required'); } @@ -12,14 +51,34 @@ export function validateConfig(config: AppConfig): string[] { errors.push('JWT_SECRET must be at least 32 characters'); } + if (!isNonEmptyString(config.auth.jwtExpiresIn)) { + errors.push('JWT_EXPIRES_IN is required'); + } + if (config.server.port < 1 || config.server.port > 65535) { errors.push('PORT must be between 1 and 65535'); } + if (!VALID_LOG_LEVELS.includes(config.logging.level as any)) { + errors.push('LOG_LEVEL must be one of error, warn, info, debug'); + } + + if (!isNonEmptyString(config.bodySizeLimit.limit)) { + errors.push('BODY_SIZE_LIMIT is required'); + } + if (config.cache.idempotencyTtlMs < 1000) { errors.push('IDEMPOTENCY_TTL_MS must be at least 1000'); } + if (config.cache.redisEnabled && !isNonEmptyString(config.cache.redisUrl)) { + errors.push('REDIS_URL is required when REDIS_ENABLED=true'); + } + + if (config.cache.idempotencyMaxEntries < 1) { + errors.push('IDEMPOTENCY_MAX_ENTRIES must be at least 1'); + } + if (config.pagination.maxLimit < config.pagination.defaultLimit) { errors.push('PAGINATION_MAX_LIMIT must be >= PAGINATION_DEFAULT_LIMIT'); } @@ -36,15 +95,35 @@ export function validateConfig(config: AppConfig): string[] { errors.push('WS_PRICE_UPDATE_INTERVAL_MS must be >= 1000'); } + if (config.ws.oracleApiUrl && !isValidUrl(config.ws.oracleApiUrl)) { + errors.push('ORACLE_API_URL must be a valid URL when set'); + } + if (config.emergency.autoPauseFailureThreshold < 1) { errors.push('AUTO_PAUSE_FAILURE_THRESHOLD must be >= 1'); } + if (config.server.env === 'production') { + if (!config.cors.allowedOrigins.length) { + errors.push('ALLOWED_ORIGINS is required in production'); + } + if (config.cors.allowedOrigins.includes('*')) { + errors.push('ALLOWED_ORIGINS must not include wildcard (*) in production'); + } + } + return errors; } export function assertValidConfig(config: AppConfig): void { const errors = validateConfig(config); + configAuditService.record({ + timestamp: new Date().toISOString(), + action: 'validated', + source: config.server.env || 'environment', + validationErrors: errors, + }); + if (errors.length > 0) { throw new ValidationError(`Config validation failed: ${errors.join('; ')}`); } diff --git a/package-lock.json b/package-lock.json index 25e0769..da584fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3249,9 +3249,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3266,9 +3263,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3283,9 +3277,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3300,9 +3291,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3317,9 +3305,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3334,9 +3319,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3351,9 +3333,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3368,9 +3347,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3385,9 +3361,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3402,9 +3375,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3419,9 +3389,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3436,9 +3403,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3453,9 +3417,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4860,9 +4821,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4877,9 +4835,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4894,9 +4849,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4911,9 +4863,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4928,9 +4877,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4945,9 +4891,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4962,9 +4905,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4979,9 +4919,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4996,9 +4933,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5013,9 +4947,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -15650,8 +15581,6 @@ "eslint": "^8.55.0", "eslint-import-resolver-typescript": "^3.6.0", "eslint-plugin-import": "^2.29.0", - "husky": "^9.1.4", - "lint-staged": "^15.2.7", "prettier": "^3.1.0", "tsx": "^4.6.0", "typescript": "^5.3.0", @@ -15693,29 +15622,6 @@ "urijs": "^1.19.1" } }, - "oracle/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "oracle/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "oracle/node_modules/eslint-import-resolver-typescript": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", @@ -15750,225 +15656,6 @@ "optional": true } } - }, - "oracle/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "oracle/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "oracle/node_modules/husky": { - "version": "9.1.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.4.tgz", - "integrity": "sha512-bho94YyReb4JV7LYWRWxZ/xr6TtOTt8cMfmQ39MQYJ7f/YE268s3GdghGwi+y4zAeqewE5zYLvuhV0M0ijsDEA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "oracle/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/lint-staged": { - "version": "15.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.7.tgz", - "integrity": "sha512-+FdVbbCZ+yoh7E/RosSdqKJyUM2OEjTciH0TFNkawKgvFp1zbGlEC39RADg+xKBG1R4mhoH2j85myBQZ5wR+lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.4", - "execa": "~8.0.1", - "lilconfig": "~3.1.1", - "listr2": "~8.2.1", - "micromatch": "~4.0.7", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.4.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "oracle/node_modules/lint-staged/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "oracle/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "oracle/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "oracle/node_modules/yaml": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", - "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } } } }