diff --git a/fluxapay_backend/.env.example b/fluxapay_backend/.env.example index ebb9e349..fc916dbb 100644 --- a/fluxapay_backend/.env.example +++ b/fluxapay_backend/.env.example @@ -7,6 +7,12 @@ PAYMENT_RATE_LIMIT_PER_MINUTE=5 PAYMENT_METADATA_MAX_BYTES=16384 PAYMENT_METADATA_MAX_DEPTH=5 +# ─── CORS Configuration ──────────────────────────────────────────────────────── +# Comma-separated list of allowed origins (e.g., "https://app.fluxapay.com,https://dashboard.fluxapay.com") +# In development, localhost is automatically allowed +# In production, you MUST set this to your actual frontend domains +# CORS_ORIGINS="https://app.fluxapay.com,https://dashboard.fluxapay.com" + # ─── Database Configuration (REQUIRED) ───────────────────────────────────────── DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fluxapay?schema=public" diff --git a/fluxapay_backend/CORS_IMPLEMENTATION.md b/fluxapay_backend/CORS_IMPLEMENTATION.md new file mode 100644 index 00000000..22e9bbca --- /dev/null +++ b/fluxapay_backend/CORS_IMPLEMENTATION.md @@ -0,0 +1,160 @@ +# CORS Environment-Based Allowlist Implementation + +## Overview +Implemented secure, environment-based CORS configuration to replace the permissive `cors()` setup in production. + +## Changes Made + +### 1. Environment Configuration (`src/config/env.config.ts`) +- Added `CORS_ORIGINS` as an optional environment variable (comma-separated string) +- Validated via Zod schema + +### 2. Environment Example (`.env.example`) +Added documentation for CORS configuration: +```bash +# ─── CORS Configuration ──────────────────────────────────────────────────────── +# Comma-separated list of allowed origins (e.g., "https://app.fluxapay.com,https://dashboard.fluxapay.com") +# In development, localhost is automatically allowed +# In production, you MUST set this to your actual frontend domains +# CORS_ORIGINS="https://app.fluxapay.com,https://dashboard.fluxapay.com" +``` + +### 3. CORS Middleware (`src/middleware/cors.middleware.ts`) +Created new middleware with environment-aware behavior: + +#### Development Mode (`NODE_ENV=development`) +- Allows all origins for developer convenience +- Supports credentials +- Includes proper headers and methods + +#### Test Mode (`NODE_ENV=test`) +- Allows wildcard origin (`*`) for easier testing +- Includes proper headers and methods + +#### Production Mode (`NODE_ENV=production`) +- **Strict origin checking** - requires explicit `CORS_ORIGINS` to be set +- Supports: + - Exact origin matching (e.g., `https://app.fluxapay.com`) + - Wildcard subdomain patterns (e.g., `*.fluxapay.com`) + - Full wildcard (`*`) if explicitly needed +- Blocks requests without valid origin +- Logs warnings for blocked origins +- Includes credentials support +- Exposes `X-Request-ID` header for tracing +- Sets max age to 24 hours + +### 4. Application Integration (`src/app.ts`) +- Replaced `app.use(cors())` with `app.use(corsMiddleware)` +- Imported new CORS middleware from `./middleware/cors.middleware` + +### 5. Comprehensive Tests (`src/middleware/__tests__/cors.middleware.test.ts`) +Created 17 test cases covering: + +#### Development Environment Tests +- ✅ Allows all origins in development +- ✅ Allows credentials in development +- ✅ Includes proper methods and headers + +#### Test Environment Tests +- ✅ Allows wildcard origin in test environment +- ✅ Includes proper methods and headers + +#### Production Environment Tests +- ✅ Blocks all origins when CORS_ORIGINS is not set +- ✅ Allows specified origins in production +- ✅ Blocks non-specified origins in production +- ✅ Blocks missing origins in production +- ✅ Supports wildcard subdomain patterns +- ✅ Allows wildcard (*) origin when explicitly set +- ✅ Handles whitespace in CORS_ORIGINS +- ✅ Includes credentials and exposed headers + +#### Preflight Request Handling Tests +- ✅ Handles OPTIONS preflight requests correctly +- ✅ Rejects preflight requests from disallowed origins + +#### Edge Cases +- ✅ Handles empty origin strings +- ✅ Trims whitespace from individual origins + +All tests passing: **17/17** ✅ + +## Usage + +### Development +No configuration needed - all origins are allowed by default. + +### Production +Set the `CORS_ORIGINS` environment variable: + +```bash +# Single origin +CORS_ORIGINS="https://app.fluxapay.com" + +# Multiple origins (comma-separated) +CORS_ORIGINS="https://app.fluxapay.com,https://dashboard.fluxapay.com" + +# Wildcard subdomain pattern +CORS_ORIGINS="*.fluxapay.com" + +# Full wildcard (use with caution) +CORS_ORIGINS="*" +``` + +## Security Features + +1. **Environment-based validation**: Origins are validated based on `NODE_ENV` +2. **Explicit allowlist in production**: Forces developers to explicitly define allowed origins +3. **Warning logs**: Logs warnings when CORS_ORIGINS is not set in production +4. **Blocked origin logging**: Logs attempts from blocked origins for monitoring +5. **Wildcard support**: Supports both exact matches and wildcard patterns +6. **Credential support**: Properly handles credentials with appropriate headers + +## Technical Details + +### Middleware Lazy Initialization +The CORS middleware uses lazy initialization to avoid environment validation issues during testing: +- Middleware is only initialized on first use +- Allows tests to set up environment variables before CORS configuration is loaded + +### Origin Matching Logic +1. Exact match check +2. Wildcard pattern matching (e.g., `*.example.com`) +3. Full wildcard support (`*`) + +### Headers Configuration +```typescript +{ + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Admin-Secret'], + exposedHeaders: ['X-Request-ID'], + maxAge: 86400 // 24 hours +} +``` + +## Migration Notes + +### Before +```typescript +import cors from 'cors'; +app.use(cors()); // Too permissive for production +``` + +### After +```typescript +import { corsMiddleware } from './middleware/cors.middleware'; +app.use(corsMiddleware); // Secure, environment-aware +``` + +## Testing + +Run the CORS middleware tests: +```bash +npm test -- cors.middleware.test.ts +``` + +All existing tests continue to work as the new middleware maintains backward compatibility in test mode. + +## Related Issue +Fixes: [Backend] CORS: Environment-based allowlist #301 diff --git a/fluxapay_backend/src/app.ts b/fluxapay_backend/src/app.ts index b4739bde..a96271b0 100644 --- a/fluxapay_backend/src/app.ts +++ b/fluxapay_backend/src/app.ts @@ -1,5 +1,4 @@ import express from "express"; -import cors from "cors"; import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; import { specs } from "./docs/swagger"; @@ -10,6 +9,7 @@ import { errorLoggingMiddleware, } from "./middleware/requestLogging.middleware"; import { metricsMiddleware } from "./middleware/metrics.middleware"; +import { corsMiddleware } from "./middleware/cors.middleware"; import merchantRoutes from "./routes/merchant.route"; import settlementRoutes from "./routes/settlement.route"; import kycRoutes from "./routes/kyc.route"; @@ -35,7 +35,8 @@ app.use(requestIdMiddleware); app.use(requestLoggingMiddleware); app.use(metricsMiddleware); -app.use(cors()); +// CORS Middleware (before routes, after observability) +app.use(corsMiddleware); app.use(express.json()); app.use( diff --git a/fluxapay_backend/src/config/env.config.ts b/fluxapay_backend/src/config/env.config.ts index d340ab36..26a71ade 100644 --- a/fluxapay_backend/src/config/env.config.ts +++ b/fluxapay_backend/src/config/env.config.ts @@ -17,6 +17,7 @@ const envSchema = z.object({ PAYMENT_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5), PAYMENT_METADATA_MAX_BYTES: z.coerce.number().int().positive().default(16384), PAYMENT_METADATA_MAX_DEPTH: z.coerce.number().int().positive().default(5), + CORS_ORIGINS: z.string().optional(), // Database (CRITICAL) DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), diff --git a/fluxapay_backend/src/middleware/__tests__/cors.middleware.test.ts b/fluxapay_backend/src/middleware/__tests__/cors.middleware.test.ts new file mode 100644 index 00000000..df0c0f34 --- /dev/null +++ b/fluxapay_backend/src/middleware/__tests__/cors.middleware.test.ts @@ -0,0 +1,313 @@ +import { getCorsOptions, corsMiddleware } from '../cors.middleware'; +import { resetEnvConfig } from '../../config/env.config'; + +/** + * Helper function to set up minimal required environment variables for testing + */ +function setupMinimalEnv() { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.JWT_SECRET = 'test-secret-key-for-testing'; + process.env.FUNDER_SECRET_KEY = 'SBS_TEST_SECRET_KEY_FOR_TESTING_ONLY_1234567890ABCDEF'; + process.env.USDC_ISSUER_PUBLIC_KEY = 'GBTEST_USDC_ISSUER_PUBLIC_KEY_FOR_TESTING_ONLY_12345'; + process.env.MASTER_VAULT_SECRET_KEY = 'SBS_TEST_VAULT_SECRET_KEY_FOR_TESTING_ONLY_123456789'; + process.env.KMS_ENCRYPTED_MASTER_SEED = 'test-encrypted-master-seed'; +} + +describe('CORS Middleware', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment config before each test + jest.resetModules(); + process.env = { ...originalEnv }; + resetEnvConfig(); + jest.clearAllMocks(); + setupMinimalEnv(); + }); + + afterEach(() => { + process.env = originalEnv; + resetEnvConfig(); + }); + + describe('Development Environment', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + process.env.CORS_ORIGINS = ''; + }); + + it('should allow all origins in development', () => { + const options = getCorsOptions(); + expect(options.origin).toBeDefined(); + expect(typeof options.origin).toBe('function'); + + // Test the origin function + const callback = jest.fn(); + (options.origin as Function)('http://localhost:3000', callback); + expect(callback).toHaveBeenCalledWith(null, true); + + callback.mockClear(); + (options.origin as Function)('http://localhost:8080', callback); + expect(callback).toHaveBeenCalledWith(null, true); + }); + + it('should allow credentials in development', () => { + const options = getCorsOptions(); + expect(options.credentials).toBe(true); + }); + + it('should include proper methods and headers in development', () => { + const options = getCorsOptions(); + expect(options.methods).toEqual([ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'OPTIONS' + ]); + expect(options.allowedHeaders).toContain('Content-Type'); + expect(options.allowedHeaders).toContain('Authorization'); + }); + }); + + describe('Test Environment', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.CORS_ORIGINS = ''; + }); + + it('should allow wildcard origin in test environment', () => { + const options = getCorsOptions(); + expect(options.origin).toBe('*'); + }); + + it('should include proper methods and headers in test', () => { + const options = getCorsOptions(); + expect(options.methods).toEqual([ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'OPTIONS' + ]); + expect(options.allowedHeaders).toContain('Content-Type'); + expect(options.allowedHeaders).toContain('Authorization'); + }); + }); + + describe('Production Environment', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should block all origins when CORS_ORIGINS is not set', () => { + process.env.CORS_ORIGINS = ''; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + // Simulate origin check + (options.origin as Function)('https://example.com', callback); + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBeInstanceOf(Error); + expect(callback.mock.calls[0][1]).toBe(false); + }); + + it('should allow specified origins in production', () => { + process.env.CORS_ORIGINS = 'https://app.fluxapay.com,https://dashboard.fluxapay.com'; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + // Test allowed origin + (options.origin as Function)('https://app.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + + callback.mockClear(); + (options.origin as Function)('https://dashboard.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + }); + + it('should block non-specified origins in production', () => { + process.env.CORS_ORIGINS = 'https://app.fluxapay.com'; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('https://evil.com', callback); + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBeInstanceOf(Error); + expect(callback.mock.calls[0][1]).toBe(false); + }); + + it('should block missing origins in production', () => { + process.env.CORS_ORIGINS = 'https://app.fluxapay.com'; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('', callback); + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBeInstanceOf(Error); + expect(callback.mock.calls[0][1]).toBe(false); + }); + + it('should support wildcard subdomain patterns', () => { + process.env.CORS_ORIGINS = '*.fluxapay.com'; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + // Test subdomain match + (options.origin as Function)('https://app.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + + callback.mockClear(); + (options.origin as Function)('https://dashboard.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + + callback.mockClear(); + (options.origin as Function)('https://evil.com', callback); + expect(callback.mock.calls[0][0]).toBeInstanceOf(Error); + expect(callback.mock.calls[0][1]).toBe(false); + }); + + it('should allow wildcard (*) origin when explicitly set', () => { + process.env.CORS_ORIGINS = '*'; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('https://any-origin.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + }); + + it('should handle whitespace in CORS_ORIGINS', () => { + process.env.CORS_ORIGINS = ' https://app.fluxapay.com , https://dashboard.fluxapay.com '; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('https://app.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + + callback.mockClear(); + (options.origin as Function)('https://dashboard.fluxapay.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + }); + + it('should include credentials and exposed headers in production', () => { + process.env.CORS_ORIGINS = 'https://app.fluxapay.com'; + resetEnvConfig(); + + const options = getCorsOptions(); + expect(options.credentials).toBe(true); + expect(options.exposedHeaders).toContain('X-Request-ID'); + }); + }); + + describe('Preflight Request Handling', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + process.env.CORS_ORIGINS = 'https://app.fluxapay.com'; + }); + + it('should handle OPTIONS preflight requests correctly', () => { + const mockReq: any = { + method: 'OPTIONS', + headers: { + origin: 'https://app.fluxapay.com', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'Content-Type, Authorization' + } + }; + + const mockRes: any = { + statusCode: 204, + headers: {} as Record, + setHeader: jest.fn((key: string, value: string) => { + mockRes.headers[key.toLowerCase()] = value; + return mockRes; + }), + end: jest.fn(), + getHeader: jest.fn() + }; + + const mockNext = jest.fn(); + + corsMiddleware(mockReq, mockRes, mockNext); + + // Verify preflight response headers + expect(mockRes.headers['access-control-allow-origin']).toBe('https://app.fluxapay.com'); + expect(mockRes.headers['access-control-allow-methods']).toContain('POST'); + expect(mockRes.headers['access-control-allow-headers']).toContain('Authorization'); + expect(mockRes.headers['access-control-max-age']).toBe('86400'); + }); + + it('should reject preflight requests from disallowed origins', () => { + const mockReq: any = { + method: 'OPTIONS', + headers: { + origin: 'https://evil.com', + 'access-control-request-method': 'POST' + } + }; + + const mockRes: any = { + statusCode: 200, + headers: {} as Record, + setHeader: jest.fn((key: string, value: string) => { + mockRes.headers[key.toLowerCase()] = value; + return mockRes; + }) + }; + + const mockNext = jest.fn(); + + // Should not call next for blocked origins - CORS will handle the error + corsMiddleware(mockReq, mockRes, mockNext); + + // The CORS middleware should block the request and not call next + // It will set appropriate error headers instead + expect(mockRes.headers['access-control-allow-origin']).toBeUndefined(); + }); + }); + + describe('Edge Cases', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should handle empty origin strings', () => { + process.env.CORS_ORIGINS = ','; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('https://example.com', callback); + expect(callback.mock.calls[0][0]).toBeInstanceOf(Error); + expect(callback.mock.calls[0][1]).toBe(false); + }); + + it('should trim whitespace from individual origins', () => { + process.env.CORS_ORIGINS = ' https://clean.com '; + resetEnvConfig(); + + const options = getCorsOptions(); + const callback = jest.fn(); + + (options.origin as Function)('https://clean.com', callback); + expect(callback).toHaveBeenCalledWith(null, true); + }); + }); +}); diff --git a/fluxapay_backend/src/middleware/cors.middleware.ts b/fluxapay_backend/src/middleware/cors.middleware.ts new file mode 100644 index 00000000..7d01a494 --- /dev/null +++ b/fluxapay_backend/src/middleware/cors.middleware.ts @@ -0,0 +1,160 @@ +import cors, { CorsOptions } from 'cors'; +import { getEnvConfig } from '../config/env.config'; + +/** + * CORS Middleware Configuration + * + * Provides secure CORS configuration based on environment variables: + * - Development: Automatically allows localhost origins + * - Production: Requires explicit CORS_ORIGINS environment variable + * - Test: Allows all origins for easier testing + */ + +/** + * Parse comma-separated CORS origins from environment variable + */ +function parseCorsOrigins(): string[] { + const config = getEnvConfig(); + + if (!config.CORS_ORIGINS || config.CORS_ORIGINS.trim() === '') { + return []; + } + + return config.CORS_ORIGINS + .split(',') + .map(origin => origin.trim()) + .filter(origin => origin.length > 0); +} + +/** + * Check if an origin is allowed + */ +function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean { + if (!origin) return false; + + // Allow wildcard for specific use cases (use with caution) + if (allowedOrigins.includes('*')) { + return true; + } + + // Check exact matches + if (allowedOrigins.includes(origin)) { + return true; + } + + // Check wildcard patterns (e.g., *.example.com) + for (const pattern of allowedOrigins) { + if (pattern.startsWith('*.')) { + const domain = pattern.substring(2); + if (origin.endsWith(domain) && origin.match(/^[^.]+\./)) { + return true; + } + } + } + + return false; +} + +/** + * Get CORS options based on environment + */ +export function getCorsOptions(): CorsOptions { + const config = getEnvConfig(); + const nodeEnv = config.NODE_ENV; + + // Development: Allow localhost with credentials + if (nodeEnv === 'development') { + return { + origin: (origin, callback) => { + // Allow all origins in development for flexibility + // In practice, you should still set CORS_ORIGINS if you have a frontend + callback(null, true); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Admin-Secret'], + maxAge: 86400, // 24 hours + }; + } + + // Test: Allow all origins for easier testing + if (nodeEnv === 'test') { + return { + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Admin-Secret'], + }; + } + + // Production: Strict origin checking + const allowedOrigins = parseCorsOrigins(); + + if (allowedOrigins.length === 0) { + console.warn('⚠️ WARNING: CORS_ORIGINS not set in production. No origins will be allowed!'); + console.warn(' Set CORS_ORIGINS environment variable (comma-separated list of allowed origins)'); + console.warn(' Example: CORS_ORIGINS="https://app.fluxapay.com,https://dashboard.fluxapay.com"'); + } + + return { + origin: (origin, callback) => { + if (!origin) { + // Block non-browser requests without origin + callback(new Error('Missing origin'), false); + return; + } + + if (isOriginAllowed(origin, allowedOrigins)) { + callback(null, true); + } else { + console.warn(`🚫 CORS: Blocked origin ${origin}`); + callback(new Error(`Origin ${origin} not allowed by CORS`), false); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Admin-Secret'], + exposedHeaders: ['X-Request-ID'], + maxAge: 86400, // 24 hours + }; +} + +/** + * CORS Middleware Factory + * Use this instead of app.use(cors()) + * Call this function to get the CORS middleware with current environment settings + */ +export function createCorsMiddleware() { + return cors(getCorsOptions()); +} + +/** + * Default CORS middleware instance + * For most use cases, use this directly: app.use(corsMiddleware) + * The middleware is lazily initialized on first use + */ +let _corsMiddleware: ReturnType | undefined; + +function getCorsMiddleware(): ReturnType { + if (!_corsMiddleware) { + _corsMiddleware = cors(getCorsOptions()); + } + return _corsMiddleware; +} + +// Export a wrapper function that behaves like middleware +export const corsMiddleware = ( + req: any, + res: any, + next: () => void +) => { + const middleware = getCorsMiddleware(); + return middleware(req, res, next); +}; + +/** + * Reset CORS options (useful for testing) + */ +export function resetCorsOptions(): void { + // This function exists for testing purposes + // The actual reset happens via environment variables +}