diff --git a/backend/.env.example b/backend/.env.example index 9e03ac1..a22536e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,9 +14,15 @@ REDIS_PORT=6379 USE_REDIS_CACHE=true # Application Configuration +# Allowed values: development | production | test +NODE_ENV=development PORT=3001 APP_NAME=STATION BACKEND +# CORS Configuration +# Production: https://station.drdnt.org +ALLOWED_ORIGIN=http://localhost:5173 + # UEX Sync Configuration UEX_SYNC_ENABLED=true UEX_CATEGORIES_SYNC_ENABLED=true diff --git a/backend/package.json b/backend/package.json index 76649e9..9160fc9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -52,6 +52,7 @@ "class-validator": "^0.14.2", "dotenv": "^16.4.5", "figlet": "^1.8.0", + "helmet": "^8.0.0", "passport": "^0.7.0", "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.1", diff --git a/backend/src/main.ts b/backend/src/main.ts index 04c2f9d..38e712b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,9 +2,11 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import * as figlet from 'figlet'; import * as dotenv from 'dotenv'; +import helmet from 'helmet'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; dotenv.config(); @@ -13,14 +15,60 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); // Application configuration - const port = process.env.PORT || 3001; - const appName = process.env.APP_NAME || 'STATION BACKEND'; + const configService = app.get(ConfigService); + const port = configService.get('PORT') ?? 3001; + const appName = configService.get('APP_NAME') ?? 'STATION BACKEND'; + + const nodeEnv = configService.get('NODE_ENV'); + const validNodeEnvs = ['production', 'development', 'test'] as const; + if ( + !nodeEnv || + !validNodeEnvs.includes(nodeEnv as (typeof validNodeEnvs)[number]) + ) { + throw new Error( + `Invalid NODE_ENV value: ${nodeEnv ?? 'undefined'}. Expected one of: ${validNodeEnvs.join(', ')}`, + ); + } + const isProduction = nodeEnv === 'production'; // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); - // Enable CORS (if needed for APIs) - app.enableCors(); + // Security headers — Swagger UI requires 'unsafe-inline' for scripts/styles, + // but Swagger is disabled in production so production uses a strict CSP. + // frameguard and hsts are set explicitly to meet security requirements. + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: [`'self'`], + baseUri: [`'self'`], + objectSrc: [`'none'`], + scriptSrc: isProduction ? [`'self'`] : [`'self'`, `'unsafe-inline'`], + styleSrc: isProduction ? [`'self'`] : [`'self'`, `'unsafe-inline'`], + imgSrc: [`'self'`, 'data:', 'https:'], + fontSrc: [`'self'`, 'https:', 'data:'], + }, + }, + frameguard: { action: 'deny' }, + hsts: isProduction + ? { maxAge: 31536000, includeSubDomains: true } + : false, + }), + ); + + // CORS — require ALLOWED_ORIGIN in production; fall back to localhost in dev. + // Use || (not ??) so a whitespace-only value is treated the same as unset. + const allowedOrigin = + configService.get('ALLOWED_ORIGIN')?.trim() || undefined; + if (!allowedOrigin && isProduction) { + throw new Error('Missing required environment variable: ALLOWED_ORIGIN'); + } + app.enableCors({ + origin: allowedOrigin ?? 'http://localhost:5173', + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); // Global Validation Pipe app.useGlobalPipes( @@ -35,7 +83,7 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); // Swagger/OpenAPI Documentation — development only - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { const config = new DocumentBuilder() .setTitle('Station API') .setDescription( @@ -84,7 +132,7 @@ async function bootstrap() { `🚀 Application '${appName}' is running on: http://localhost:${port}`, 'Bootstrap', ); - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { Logger.log( `📚 Swagger documentation available at: http://localhost:${port}/api/docs`, 'Bootstrap', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c590f..bfc249e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: figlet: specifier: ^1.8.0 version: 1.9.4 + helmet: + specifier: ^8.0.0 + version: 8.1.0 passport: specifier: ^0.7.0 version: 0.7.0 @@ -2673,6 +2676,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -7242,6 +7249,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1