From ea0c13c3b191515ad3011b703af1db19cadab76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 22:03:11 -0300 Subject: [PATCH 1/2] feat: add environment-based CORS configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded * with ALLOWED_ORIGINS env var - Support comma-separated origins list - Add origin validation callback - Include account-id and X-Request-ID headers - Set 24h preflight cache (maxAge) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/backend/.env.example | 3 +++ apps/backend/src/main.ts | 52 +++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ad9b515..3244439 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -18,3 +18,6 @@ SERVICE_ACCOUNT={...} PORT=3000 NODE_ENV=development + +# CORS - comma-separated allowed origins, or * for all (default: *) +ALLOWED_ORIGINS=http://localhost:3001,http://localhost:3000 diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 93f0b1b..ed1a718 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -4,14 +4,22 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { HttpExceptionFilter } from './filters/http-exception.filter'; +function getCorsOrigins(): string[] | '*' { + const originsEnv = process.env.ALLOWED_ORIGINS || '*'; + if (originsEnv === '*') return '*'; + return originsEnv.split(',').map((origin) => origin.trim()); +} + async function bootstrap() { const app = await NestFactory.create(AppModule); - app.useGlobalPipes(new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - })); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ); app.useGlobalFilters(new HttpExceptionFilter()); @@ -24,11 +32,39 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); - //TODO: Fazer um cors decente + const allowedOrigins = getCorsOrigins(); + app.enableCors({ - origin: '*', // Substitua pela URL do seu frontend - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + origin: (origin, callback) => { + // Allow requests with no origin (mobile apps, curl, server-to-server) + if (!origin) { + callback(null, true); + return; + } + + // Allow all if wildcard + if (allowedOrigins === '*') { + callback(null, true); + return; + } + + // Check if origin is in allowed list + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], credentials: true, + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'account-id', + 'X-Request-ID', + ], + exposedHeaders: ['X-Request-ID'], + maxAge: 86400, // 24 hours }); await app.listen(process.env.PORT ?? 3000); From 466e48583448c40c5d7d86a420a6ea1421ea7207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 23:20:20 -0300 Subject: [PATCH 2/2] test: add CORS configuration tests and refactor for testability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract CORS logic into cors.config.ts utility - Add getCorsOrigins() for parsing ALLOWED_ORIGINS env var - Add createCorsOriginCallback() for origin validation - Add corsOptions constant with default CORS settings - Refactor main.ts to use extracted utilities - Add 23 tests covering: - Wildcard vs specific origins parsing - Origin callback with allowed/rejected origins - Mobile/curl requests (no origin) - Security edge cases (similar domains, protocol, ports) - CORS options validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/backend/src/config/cors.config.spec.ts | 193 ++++++++++++++++++++ apps/backend/src/config/cors.config.ts | 57 ++++++ apps/backend/src/main.ts | 43 +---- 3 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 apps/backend/src/config/cors.config.spec.ts create mode 100644 apps/backend/src/config/cors.config.ts diff --git a/apps/backend/src/config/cors.config.spec.ts b/apps/backend/src/config/cors.config.spec.ts new file mode 100644 index 0000000..4f7a7b8 --- /dev/null +++ b/apps/backend/src/config/cors.config.spec.ts @@ -0,0 +1,193 @@ +import { + getCorsOrigins, + createCorsOriginCallback, + corsOptions, + CorsOrigins, +} from './cors.config'; + +describe('CORS Configuration', () => { + describe('getCorsOrigins', () => { + const originalEnv = process.env.ALLOWED_ORIGINS; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ALLOWED_ORIGINS; + } else { + process.env.ALLOWED_ORIGINS = originalEnv; + } + }); + + it('should return "*" when env value is "*"', () => { + const result = getCorsOrigins('*'); + expect(result).toBe('*'); + }); + + it('should return "*" when env value is undefined and env var not set', () => { + delete process.env.ALLOWED_ORIGINS; + const result = getCorsOrigins(); + expect(result).toBe('*'); + }); + + it('should return array when env value is comma-separated origins', () => { + const result = getCorsOrigins('https://a.com,https://b.com'); + expect(result).toEqual(['https://a.com', 'https://b.com']); + }); + + it('should trim whitespace from origins', () => { + const result = getCorsOrigins(' https://a.com , https://b.com '); + expect(result).toEqual(['https://a.com', 'https://b.com']); + }); + + it('should return single-item array for single origin', () => { + const result = getCorsOrigins('https://app.example.com'); + expect(result).toEqual(['https://app.example.com']); + }); + + it('should read from process.env.ALLOWED_ORIGINS when no param provided', () => { + process.env.ALLOWED_ORIGINS = 'https://from-env.com'; + const result = getCorsOrigins(); + expect(result).toEqual(['https://from-env.com']); + }); + + it('should prefer parameter over environment variable', () => { + process.env.ALLOWED_ORIGINS = 'https://from-env.com'; + const result = getCorsOrigins('https://from-param.com'); + expect(result).toEqual(['https://from-param.com']); + }); + }); + + describe('createCorsOriginCallback', () => { + describe('with wildcard origins', () => { + const callback = createCorsOriginCallback('*'); + + it('should allow requests with no origin', (done) => { + callback(undefined, (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it('should allow any origin', (done) => { + callback('https://any-origin.com', (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + }); + + describe('with specific origins', () => { + const allowedOrigins: CorsOrigins = [ + 'https://app.example.com', + 'https://admin.example.com', + ]; + const callback = createCorsOriginCallback(allowedOrigins); + + it('should allow requests with no origin (mobile/curl)', (done) => { + callback(undefined, (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it('should allow origin in the allowed list', (done) => { + callback('https://app.example.com', (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it('should allow second origin in the allowed list', (done) => { + callback('https://admin.example.com', (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it('should reject origin not in allowed list', (done) => { + callback('https://malicious.com', (err, allow) => { + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('not allowed by CORS'); + expect(err?.message).toContain('https://malicious.com'); + expect(allow).toBeUndefined(); + done(); + }); + }); + + it('should reject similar but different origin', (done) => { + callback('https://app.example.com.attacker.com', (err, allow) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + + it('should reject origin with different protocol', (done) => { + callback('http://app.example.com', (err, allow) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + + it('should reject origin with port when not specified', (done) => { + callback('https://app.example.com:8080', (err, allow) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + }); + + describe('with empty origins array', () => { + const callback = createCorsOriginCallback([]); + + it('should allow requests with no origin', (done) => { + callback(undefined, (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it('should reject all origins', (done) => { + callback('https://any.com', (err, allow) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + }); + }); + + describe('corsOptions', () => { + it('should include all required HTTP methods', () => { + expect(corsOptions.methods).toContain('GET'); + expect(corsOptions.methods).toContain('POST'); + expect(corsOptions.methods).toContain('PUT'); + expect(corsOptions.methods).toContain('PATCH'); + expect(corsOptions.methods).toContain('DELETE'); + expect(corsOptions.methods).toContain('OPTIONS'); + expect(corsOptions.methods).toContain('HEAD'); + }); + + it('should have credentials enabled', () => { + expect(corsOptions.credentials).toBe(true); + }); + + it('should include required headers in allowedHeaders', () => { + expect(corsOptions.allowedHeaders).toContain('Content-Type'); + expect(corsOptions.allowedHeaders).toContain('Authorization'); + expect(corsOptions.allowedHeaders).toContain('account-id'); + expect(corsOptions.allowedHeaders).toContain('X-Request-ID'); + }); + + it('should expose X-Request-ID header', () => { + expect(corsOptions.exposedHeaders).toContain('X-Request-ID'); + }); + + it('should have maxAge of 24 hours (86400 seconds)', () => { + expect(corsOptions.maxAge).toBe(86400); + }); + }); +}); diff --git a/apps/backend/src/config/cors.config.ts b/apps/backend/src/config/cors.config.ts new file mode 100644 index 0000000..102e93d --- /dev/null +++ b/apps/backend/src/config/cors.config.ts @@ -0,0 +1,57 @@ +/** + * CORS configuration utilities. + * + * Provides testable functions for CORS origin validation. + */ + +export type CorsOrigins = string[] | '*'; + +/** + * Parse CORS origins from environment variable. + * Returns '*' for wildcard, or array of trimmed origins. + */ +export function getCorsOrigins(envValue?: string): CorsOrigins { + const originsEnv = envValue ?? process.env.ALLOWED_ORIGINS ?? '*'; + if (originsEnv === '*') return '*'; + return originsEnv.split(',').map((origin) => origin.trim()); +} + +/** + * CORS origin callback for use with NestJS app.enableCors() + */ +export function createCorsOriginCallback(allowedOrigins: CorsOrigins) { + return ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void, + ) => { + // Allow requests with no origin (mobile apps, curl, server-to-server) + if (!origin) { + callback(null, true); + return; + } + + // Allow all if wildcard + if (allowedOrigins === '*') { + callback(null, true); + return; + } + + // Check if origin is in allowed list + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }; +} + +/** + * Default CORS options for production use + */ +export const corsOptions = { + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + credentials: true, + allowedHeaders: ['Content-Type', 'Authorization', 'account-id', 'X-Request-ID'], + exposedHeaders: ['X-Request-ID'], + maxAge: 86400, // 24 hours +}; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index ed1a718..8acd5af 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -3,12 +3,11 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { HttpExceptionFilter } from './filters/http-exception.filter'; - -function getCorsOrigins(): string[] | '*' { - const originsEnv = process.env.ALLOWED_ORIGINS || '*'; - if (originsEnv === '*') return '*'; - return originsEnv.split(',').map((origin) => origin.trim()); -} +import { + getCorsOrigins, + createCorsOriginCallback, + corsOptions, +} from './config/cors.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -35,36 +34,8 @@ async function bootstrap() { const allowedOrigins = getCorsOrigins(); app.enableCors({ - origin: (origin, callback) => { - // Allow requests with no origin (mobile apps, curl, server-to-server) - if (!origin) { - callback(null, true); - return; - } - - // Allow all if wildcard - if (allowedOrigins === '*') { - callback(null, true); - return; - } - - // Check if origin is in allowed list - if (allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); - } - }, - methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], - credentials: true, - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'account-id', - 'X-Request-ID', - ], - exposedHeaders: ['X-Request-ID'], - maxAge: 86400, // 24 hours + origin: createCorsOriginCallback(allowedOrigins), + ...corsOptions, }); await app.listen(process.env.PORT ?? 3000);