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
2 changes: 1 addition & 1 deletion api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
32 changes: 32 additions & 0 deletions api/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
25 changes: 25 additions & 0 deletions api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
81 changes: 80 additions & 1 deletion api/src/config/validators.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,84 @@
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');
}

if (!config.auth.jwtSecret || config.auth.jwtSecret.length < 32) {
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');
}
Expand All @@ -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('; ')}`);
}
Expand Down
Loading
Loading