diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..90a4441 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,41 @@ +# Security headers + +The API configures baseline browser security headers during bootstrap in +`src/config/security.config.ts`. + +## Helmet policy + +`helmet()` is configured with: + +- Content Security Policy limited to `self`, with inline scripts and styles kept + only so the Swagger UI at `/api/docs` can render correctly. +- HSTS with `max-age=31536000`, `includeSubDomains`, and `preload`. +- `X-Content-Type-Options: nosniff`. +- `X-XSS-Protection: 0` through Helmet's `xssFilter` middleware. +- `Referrer-Policy: strict-origin-when-cross-origin`. +- `X-Frame-Options: DENY`. +- `X-Permitted-Cross-Domain-Policies: none`. +- `Permissions-Policy: geolocation=(), camera=(), microphone=()`. + +If Swagger UI is moved behind a CDN or external asset host, add only the exact +host needed to the relevant CSP directive. + +## CORS + +Set `CORS_ORIGIN` to control browser origins: + +```bash +CORS_ORIGIN=https://app.stellartip.dev +``` + +Multiple origins are comma-separated: + +```bash +CORS_ORIGIN=https://app.stellartip.dev,https://admin.stellartip.dev +``` + +`CORS_ORIGIN=*` is allowed for local or public read-only deployments, but the +API refuses credentialed CORS in that mode. Specific origins enable credentials. + +Avoid using wildcard CORS for production sessions, dashboards, or any route that +depends on cookies or authorization headers. diff --git a/src/config/security.config.spec.ts b/src/config/security.config.spec.ts new file mode 100644 index 0000000..73decc5 --- /dev/null +++ b/src/config/security.config.spec.ts @@ -0,0 +1,88 @@ +import { Controller, Get, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { + configureSecurity, + createCorsOptions, + PERMISSIONS_POLICY_HEADER, +} from './security.config'; + +@Controller() +class SecurityHeadersController { + @Get('/headers') + headers(): string { + return 'ok'; + } +} + +describe('security configuration', () => { + const originalCorsOrigin = process.env.CORS_ORIGIN; + + afterEach(() => { + process.env.CORS_ORIGIN = originalCorsOrigin; + }); + + it('refuses credentials when wildcard CORS is configured', () => { + expect(createCorsOptions('*')).toMatchObject({ + origin: '*', + credentials: false, + }); + }); + + it('allows credentials for specific CORS origins', () => { + expect( + createCorsOptions( + 'https://app.stellartip.dev, https://admin.stellartip.dev', + ), + ).toMatchObject({ + origin: ['https://app.stellartip.dev', 'https://admin.stellartip.dev'], + credentials: true, + }); + }); + + it('sets the required security headers on responses', async () => { + process.env.CORS_ORIGIN = 'https://app.stellartip.dev'; + + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [SecurityHeadersController], + }).compile(); + const app: INestApplication = moduleRef.createNestApplication(); + configureSecurity(app); + await app.init(); + + try { + const response = await request(app.getHttpServer()) + .get('/headers') + .set('Origin', 'https://app.stellartip.dev') + .expect(200); + + expect(response.headers['content-security-policy']).toContain( + "default-src 'self'", + ); + expect(response.headers['content-security-policy']).toContain( + "script-src 'self' 'unsafe-inline'", + ); + expect(response.headers['strict-transport-security']).toBe( + 'max-age=31536000; includeSubDomains; preload', + ); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['x-frame-options']).toBe('DENY'); + expect(response.headers['x-permitted-cross-domain-policies']).toBe( + 'none', + ); + expect(response.headers['referrer-policy']).toBe( + 'strict-origin-when-cross-origin', + ); + expect(response.headers['permissions-policy']).toBe( + PERMISSIONS_POLICY_HEADER, + ); + expect(response.headers['access-control-allow-origin']).toBe( + 'https://app.stellartip.dev', + ); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + } finally { + await app.close(); + } + }); +}); diff --git a/src/config/security.config.ts b/src/config/security.config.ts new file mode 100644 index 0000000..4a77c09 --- /dev/null +++ b/src/config/security.config.ts @@ -0,0 +1,92 @@ +import type { INestApplication } from '@nestjs/common'; +import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import type { RequestHandler } from 'express'; +import helmet from 'helmet'; +import type { HelmetOptions } from 'helmet'; + +const SELF = "'self'"; +const INLINE = "'unsafe-inline'"; +const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; + +export const PERMISSIONS_POLICY_HEADER = + 'geolocation=(), camera=(), microphone=()'; + +export function createHelmetOptions(): HelmetOptions { + return { + contentSecurityPolicy: { + useDefaults: true, + directives: { + 'default-src': [SELF], + 'base-uri': [SELF], + 'font-src': [SELF, 'data:'], + 'form-action': [SELF], + 'frame-ancestors': ["'none'"], + 'img-src': [SELF, 'data:', 'validator.swagger.io'], + 'object-src': ["'none'"], + 'script-src': [SELF, INLINE], + 'style-src': [SELF, INLINE], + 'connect-src': [SELF], + }, + }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, + noSniff: true, + xssFilter: true, + referrerPolicy: { + policy: 'strict-origin-when-cross-origin', + }, + frameguard: { + action: 'deny', + }, + permittedCrossDomainPolicies: { + permittedPolicies: 'none', + }, + }; +} + +export function createCorsOptions( + rawOrigin = process.env.CORS_ORIGIN, +): CorsOptions { + const origin = parseCorsOrigin(rawOrigin); + + return { + origin, + methods: CORS_METHODS, + credentials: origin !== '*', + }; +} + +export function parseCorsOrigin( + rawOrigin: string | undefined, +): string | string[] { + if (!rawOrigin || rawOrigin.trim() === '') { + return '*'; + } + + const origins = rawOrigin + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + + if (origins.length === 0 || origins.includes('*')) { + return '*'; + } + + return origins.length === 1 ? origins[0] : origins; +} + +export function permissionsPolicy(): RequestHandler { + return (_req, res, next) => { + res.setHeader('Permissions-Policy', PERMISSIONS_POLICY_HEADER); + next(); + }; +} + +export function configureSecurity(app: INestApplication): void { + app.use(helmet(createHelmetOptions())); + app.use(permissionsPolicy()); + app.enableCors(createCorsOptions()); +} diff --git a/src/main.ts b/src/main.ts index 608a567..f170f51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,9 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import helmet from 'helmet'; import * as compression from 'compression'; import { StructuredLogger } from './shared/logging/logging.config'; +import { configureSecurity } from './config/security.config'; async function bootstrap(): Promise { const appLogger = new StructuredLogger(); @@ -15,19 +15,12 @@ async function bootstrap(): Promise { logger: appLogger, }); - // Security headers - app.use(helmet()); + // Security headers and CORS + configureSecurity(app); // Response compression app.use(compression()); - // CORS - app.enableCors({ - origin: process.env.CORS_ORIGIN || '*', - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - credentials: true, - }); - // Global validation pipes app.useGlobalPipes( new ValidationPipe({