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/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 93f0b1b..8acd5af 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -3,15 +3,22 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { HttpExceptionFilter } from './filters/http-exception.filter'; +import { + getCorsOrigins, + createCorsOriginCallback, + corsOptions, +} from './config/cors.config'; 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 +31,11 @@ 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', - credentials: true, + origin: createCorsOriginCallback(allowedOrigins), + ...corsOptions, }); await app.listen(process.env.PORT ?? 3000);