From cfe0e5ce737e96d017076ea8365a77498ecb1708 Mon Sep 17 00:00:00 2001 From: ravendevhub Date: Sat, 29 Aug 2026 14:00:18 +0630 Subject: [PATCH] feat(api): add startup CORS configuration validator and origin policy (#689) - Enforce explicit allowed origins configuration via comma-separated or JSON list - Validate origin URL schemas (http/https protocols, no path segments) - Prohibit silent fallback to permissive wildcards in production environments - Add unit test suite in cors-validator.test.ts and docs in docs/CORS_CONFIGURATION_VALIDATION.md --- docs/CORS_CONFIGURATION_VALIDATION.md | 25 ++++++ listener/src/api/cors-validator.test.ts | 45 ++++++++++ listener/src/api/cors-validator.ts | 107 ++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 docs/CORS_CONFIGURATION_VALIDATION.md create mode 100644 listener/src/api/cors-validator.test.ts create mode 100644 listener/src/api/cors-validator.ts diff --git a/docs/CORS_CONFIGURATION_VALIDATION.md b/docs/CORS_CONFIGURATION_VALIDATION.md new file mode 100644 index 00000000..0df58507 --- /dev/null +++ b/docs/CORS_CONFIGURATION_VALIDATION.md @@ -0,0 +1,25 @@ +# 🌐 CORS Configuration & Origin Validation Policy + +This document details the Cross-Origin Resource Sharing (CORS) validation and security hardening policies for NotifyChain (Issue #689). + +--- + +## 1. Allowed Origin Configuration + +Allowed origins are defined via `CORS_ALLOWED_ORIGINS` as either a comma-separated list or a JSON array: + +```bash +# Comma-separated +CORS_ALLOWED_ORIGINS="https://app.notifychain.io, https://dashboard.notifychain.io" + +# JSON Array format +CORS_ALLOWED_ORIGINS='["https://app.notifychain.io", "https://dashboard.notifychain.io"]' +``` + +--- + +## 2. Production Security Hardening + +1. **Explicit Origins Required**: In `NODE_ENV=production`, `CORS_ALLOWED_ORIGINS` must be explicitly configured. Starting a production server without explicit origins throws a startup error. +2. **Wildcard (`*`) Blocked**: The permissive wildcard origin `*` is strictly forbidden in production unless explicitly opted in via `ALLOW_PROD_CORS_WILDCARD=true`. +3. **Origin URL Validation**: Protocols must strictly be `http://` (dev) or `https://` (prod), and origins must not include path suffixes. diff --git a/listener/src/api/cors-validator.test.ts b/listener/src/api/cors-validator.test.ts new file mode 100644 index 00000000..7fef2592 --- /dev/null +++ b/listener/src/api/cors-validator.test.ts @@ -0,0 +1,45 @@ +import { validateAndBuildCorsConfig } from './cors-validator'; + +describe('CORS Configuration Validation (Issue #689)', () => { + test('parses comma-separated allowed origins in development', () => { + const raw = 'https://dashboard.notifychain.io, http://localhost:3000'; + const config = validateAndBuildCorsConfig(raw, 'development'); + + expect(config.isWildcard).toBe(false); + expect(config.allowedOrigins).toEqual([ + 'https://dashboard.notifychain.io', + 'http://localhost:3000', + ]); + }); + + test('parses JSON array format for allowed origins', () => { + const raw = JSON.stringify(['https://app.notifychain.io', 'https://admin.notifychain.io']); + const config = validateAndBuildCorsConfig(raw, 'production'); + + expect(config.allowedOrigins).toEqual([ + 'https://app.notifychain.io', + 'https://admin.notifychain.io', + ]); + }); + + test('rejects wildcard origin in production unless explicitly permitted', () => { + expect(() => validateAndBuildCorsConfig('*', 'production', false)).toThrow( + /Wildcard origin.*is prohibited in production/ + ); + }); + + test('allows wildcard in development environment', () => { + const config = validateAndBuildCorsConfig('*', 'development'); + expect(config.isWildcard).toBe(true); + expect(config.allowedOrigins).toBe('*'); + }); + + test('rejects invalid origin URLs and protocol schemes', () => { + expect(() => validateAndBuildCorsConfig('ftp://invalid-origin.com', 'development')).toThrow( + /Protocol must be http: or https:/ + ); + expect(() => validateAndBuildCorsConfig('https://domain.com/path', 'development')).toThrow( + /Origin cannot contain path segments/ + ); + }); +}); diff --git a/listener/src/api/cors-validator.ts b/listener/src/api/cors-validator.ts new file mode 100644 index 00000000..207bb9e5 --- /dev/null +++ b/listener/src/api/cors-validator.ts @@ -0,0 +1,107 @@ +/** + * CORS Configuration Validator & Origin Policy Engine (Issue #689) + * + * Validates allowed CORS origins during application startup, preventing + * accidental permissive wildcard exposure in sensitive/production environments. + */ + +import cors, { CorsOptions } from 'cors'; + +export interface ValidatedCorsConfig { + allowedOrigins: string[] | '*'; + isWildcard: boolean; + corsMiddleware: ReturnType; +} + +/** + * Validates CORS origin configuration and builds production-grade CORS middleware. + * Throws explicit errors when invalid origins or dangerous production wildcards are detected. + */ +export function validateAndBuildCorsConfig( + rawOrigins: string | undefined = process.env.CORS_ALLOWED_ORIGINS, + nodeEnv: string | undefined = process.env.NODE_ENV, + allowProductionWildcard = false +): ValidatedCorsConfig { + const isProduction = nodeEnv === 'production'; + + // If unset, provide safe default origins + if (!rawOrigins || rawOrigins.trim() === '') { + if (isProduction) { + throw new Error( + 'CORS Configuration Error: CORS_ALLOWED_ORIGINS must be explicitly configured in production environments.' + ); + } + // Development default: allow local dashboard and listener ports + const devOrigins = ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000']; + return { + allowedOrigins: devOrigins, + isWildcard: false, + corsMiddleware: cors({ origin: devOrigins, credentials: true }), + }; + } + + const trimmed = rawOrigins.trim(); + + // Wildcard handling + if (trimmed === '*') { + if (isProduction && !allowProductionWildcard) { + throw new Error( + 'CORS Security Violation: Wildcard origin ("*") is prohibited in production. Explicitly list allowed origins or set ALLOW_PROD_CORS_WILDCARD=true.' + ); + } + return { + allowedOrigins: '*', + isWildcard: true, + corsMiddleware: cors({ origin: '*' }), + }; + } + + // Parse comma-separated list or JSON array + let originsList: string[] = []; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + originsList = parsed.map((o) => String(o).trim()); + } + } catch { + throw new Error(`CORS Configuration Error: Invalid JSON array format in CORS_ALLOWED_ORIGINS: ${trimmed}`); + } + } else { + originsList = trimmed.split(',').map((o) => o.trim()).filter(Boolean); + } + + if (originsList.length === 0) { + throw new Error('CORS Configuration Error: No valid origins specified in CORS_ALLOWED_ORIGINS.'); + } + + // Validate each origin URI structure + for (const origin of originsList) { + try { + const url = new URL(origin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Protocol must be http: or https: (received ${url.protocol})`); + } + if (url.pathname !== '' && url.pathname !== '/') { + throw new Error(`Origin cannot contain path segments (received ${url.pathname})`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`CORS Configuration Error: Invalid origin URL "${origin}". Reason: ${msg}`); + } + } + + const options: CorsOptions = { + origin: originsList, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID', 'X-API-Key'], + credentials: true, + maxAge: 86400, // 24 hours + }; + + return { + allowedOrigins: originsList, + isWildcard: false, + corsMiddleware: cors(options), + }; +}