Skip to content
Open
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
25 changes: 25 additions & 0 deletions docs/CORS_CONFIGURATION_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions listener/src/api/cors-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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/
);
});
});
107 changes: 107 additions & 0 deletions listener/src/api/cors-validator.ts
Original file line number Diff line number Diff line change
@@ -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<typeof cors>;
}

/**
* 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),
};
}