From bb60b9975af7fb366be0659e71d47c55518456af Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 01:32:44 -0400 Subject: [PATCH 1/8] feat: add Helmet security headers and restrict CORS to known origin Install helmet for security headers, apply before all other middleware. Restrict CORS from wildcard to configurable ALLOWED_ORIGIN env variable. CORS allows credentials and standard HTTP methods. Production CORS origin should be set to https://station.drdnt.org. Development default: http://localhost:5173 (see .env.example). Closes #94 --- backend/.env.example | 4 ++++ backend/package.json | 4 ++++ backend/src/main.ts | 18 ++++++++++++++---- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 9e03ac1..2130a9c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,10 @@ USE_REDIS_CACHE=true 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..3ece5ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,8 +50,11 @@ "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "figlet": "^1.8.0", + "helmet": "^8.0.0", + "joi": "^17.13.3", "passport": "^0.7.0", "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.1", @@ -68,6 +71,7 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.3.9", "@types/express": "^4.17.21", + "@types/cookie-parser": "^1.4.8", "@types/figlet": "^1.7.0", "@types/jest": "^29.5.12", "@types/node": "^20.12.12", diff --git a/backend/src/main.ts b/backend/src/main.ts index 04c2f9d..b4e5012 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,22 @@ 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'; // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); - // Enable CORS (if needed for APIs) - app.enableCors(); + // Security headers — must be registered before other middleware + app.use(helmet()); + + // CORS — restrict to known origin + app.enableCors({ + origin: configService.get('ALLOWED_ORIGIN'), + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); // Global Validation Pipe app.useGlobalPipes( From 835d733faf0d4202f510cf201f7fe717dff7aa1d Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 20:02:31 -0400 Subject: [PATCH 2/8] chore: update pnpm-lock.yaml to match package.json Lockfile was out of sync with the helmet, cookie-parser, and @types/cookie-parser additions, causing CI frozen-lockfile check to fail. --- pnpm-lock.yaml | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c590f..6077601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,12 +77,21 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 dotenv: specifier: ^16.4.5 version: 16.6.1 figlet: specifier: ^1.8.0 version: 1.9.4 + helmet: + specifier: ^8.0.0 + version: 8.1.0 + joi: + specifier: ^17.13.3 + version: 17.13.3 passport: specifier: ^0.7.0 version: 0.7.0 @@ -123,6 +132,9 @@ importers: '@nestjs/testing': specifier: ^10.3.9 version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.10(@types/express@4.17.25) '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -713,6 +725,12 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1285,6 +1303,15 @@ packages: cpu: [x64] os: [win32] + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1367,6 +1394,11 @@ packages: '@types/content-disposition@0.5.9': resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -2061,6 +2093,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2068,6 +2104,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -2673,6 +2713,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==} @@ -3031,6 +3075,9 @@ packages: node-notifier: optional: true + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4969,6 +5016,12 @@ snapshots: '@eslint/js@9.39.1': {} + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5604,6 +5657,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -5710,6 +5771,10 @@ snapshots: '@types/content-disposition@0.5.9': {} + '@types/cookie-parser@1.4.10(@types/express@4.17.25)': + dependencies: + '@types/express': 4.17.25 + '@types/cookiejar@2.1.5': {} '@types/cookies@0.9.2': @@ -6541,10 +6606,17 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} cookie@0.7.1: {} + cookie@0.7.2: {} + cookiejar@2.1.4: {} core-util-is@1.0.3: {} @@ -7242,6 +7314,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 @@ -7806,6 +7880,14 @@ snapshots: - supports-color - ts-node + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + js-tokens@4.0.0: {} js-yaml@3.14.2: From a30d5219976bf74e23f6b886455fd9a58b8b56e5 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 01:08:08 -0400 Subject: [PATCH 3/8] fix: address review comments on Helmet CSP and CORS config - Configure Helmet with explicit CSP directives that allow Swagger UI's inline scripts and styles (unsafe-inline on scriptSrc/styleSrc); default Helmet CSP blocks Swagger and leaves the docs page blank - Extract ALLOWED_ORIGIN into a variable with a localhost fallback so CORS never silently falls back to permissive defaults when the env var is unset; origin validation is enforced at startup in PR #118 - Remove cookie-parser, @types/cookie-parser, and joi from package.json as they are unused on this branch; those deps are introduced in the cookie auth (#117) and env validation (#118) PRs respectively --- backend/package.json | 3 -- backend/src/main.ts | 24 ++++++++++++--- pnpm-lock.yaml | 73 -------------------------------------------- 3 files changed, 20 insertions(+), 80 deletions(-) diff --git a/backend/package.json b/backend/package.json index 3ece5ea..9160fc9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,11 +50,9 @@ "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", - "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "figlet": "^1.8.0", "helmet": "^8.0.0", - "joi": "^17.13.3", "passport": "^0.7.0", "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.1", @@ -71,7 +69,6 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.3.9", "@types/express": "^4.17.21", - "@types/cookie-parser": "^1.4.8", "@types/figlet": "^1.7.0", "@types/jest": "^29.5.12", "@types/node": "^20.12.12", diff --git a/backend/src/main.ts b/backend/src/main.ts index b4e5012..af1334e 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -22,12 +22,28 @@ async function bootstrap() { // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); - // Security headers — must be registered before other middleware - app.use(helmet()); + // Security headers — configure CSP to allow Swagger UI inline scripts/styles + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: [`'self'`], + baseUri: [`'self'`], + objectSrc: [`'none'`], + scriptSrc: [`'self'`, `'unsafe-inline'`], + styleSrc: [`'self'`, `'unsafe-inline'`], + imgSrc: [`'self'`, 'data:', 'https:'], + fontSrc: [`'self'`, 'https:', 'data:'], + }, + }, + }), + ); - // CORS — restrict to known origin + // CORS — restrict to known origin; fallback keeps local dev working if unset + const allowedOrigin = + configService.get('ALLOWED_ORIGIN') ?? 'http://localhost:5173'; app.enableCors({ - origin: configService.get('ALLOWED_ORIGIN'), + origin: allowedOrigin, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6077601..bfc249e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,9 +77,6 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 - cookie-parser: - specifier: ^1.4.7 - version: 1.4.7 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -89,9 +86,6 @@ importers: helmet: specifier: ^8.0.0 version: 8.1.0 - joi: - specifier: ^17.13.3 - version: 17.13.3 passport: specifier: ^0.7.0 version: 0.7.0 @@ -132,9 +126,6 @@ importers: '@nestjs/testing': specifier: ^10.3.9 version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) - '@types/cookie-parser': - specifier: ^1.4.8 - version: 1.4.10(@types/express@4.17.25) '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -725,12 +716,6 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hapi/hoek@9.3.0': - resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - - '@hapi/topo@5.1.0': - resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} - '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1303,15 +1288,6 @@ packages: cpu: [x64] os: [win32] - '@sideway/address@4.1.5': - resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} - - '@sideway/formula@3.0.1': - resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} - - '@sideway/pinpoint@2.0.0': - resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1394,11 +1370,6 @@ packages: '@types/content-disposition@0.5.9': resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} - '@types/cookie-parser@1.4.10': - resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} - peerDependencies: - '@types/express': '*' - '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -2093,10 +2064,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-parser@1.4.7: - resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} - engines: {node: '>= 0.8.0'} - cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2104,10 +2071,6 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -3075,9 +3038,6 @@ packages: node-notifier: optional: true - joi@17.13.3: - resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5016,12 +4976,6 @@ snapshots: '@eslint/js@9.39.1': {} - '@hapi/hoek@9.3.0': {} - - '@hapi/topo@5.1.0': - dependencies: - '@hapi/hoek': 9.3.0 - '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5657,14 +5611,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true - '@sideway/address@4.1.5': - dependencies: - '@hapi/hoek': 9.3.0 - - '@sideway/formula@3.0.1': {} - - '@sideway/pinpoint@2.0.0': {} - '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -5771,10 +5717,6 @@ snapshots: '@types/content-disposition@0.5.9': {} - '@types/cookie-parser@1.4.10(@types/express@4.17.25)': - dependencies: - '@types/express': 4.17.25 - '@types/cookiejar@2.1.5': {} '@types/cookies@0.9.2': @@ -6606,17 +6548,10 @@ snapshots: convert-source-map@2.0.0: {} - cookie-parser@1.4.7: - dependencies: - cookie: 0.7.2 - cookie-signature: 1.0.6 - cookie-signature@1.0.6: {} cookie@0.7.1: {} - cookie@0.7.2: {} - cookiejar@2.1.4: {} core-util-is@1.0.3: {} @@ -7880,14 +7815,6 @@ snapshots: - supports-color - ts-node - joi@17.13.3: - dependencies: - '@hapi/hoek': 9.3.0 - '@hapi/topo': 5.1.0 - '@sideway/address': 4.1.5 - '@sideway/formula': 3.0.1 - '@sideway/pinpoint': 2.0.0 - js-tokens@4.0.0: {} js-yaml@3.14.2: From 8696a492a0e7cd548e9121f06018da81186400da Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 01:23:45 -0400 Subject: [PATCH 4/8] fix: tighten CSP in production and fail fast on missing ALLOWED_ORIGIN - Use strict CSP (no 'unsafe-inline') in production; Swagger is disabled in production so the relaxed CSP it requires is unnecessary and should not weaken security there - Throw at startup in production if ALLOWED_ORIGIN is unset; dev/test environments still fall back to http://localhost:5173 --- backend/src/main.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/src/main.ts b/backend/src/main.ts index af1334e..ea4cfdd 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -18,11 +18,13 @@ async function bootstrap() { const configService = app.get(ConfigService); const port = configService.get('PORT') || 3001; const appName = configService.get('APP_NAME') || 'STATION BACKEND'; + const isProduction = process.env.NODE_ENV === 'production'; // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); - // Security headers — configure CSP to allow Swagger UI inline scripts/styles + // Security headers — Swagger UI requires 'unsafe-inline' for scripts/styles, + // but Swagger is disabled in production so production uses a strict CSP. app.use( helmet({ contentSecurityPolicy: { @@ -30,8 +32,8 @@ async function bootstrap() { defaultSrc: [`'self'`], baseUri: [`'self'`], objectSrc: [`'none'`], - scriptSrc: [`'self'`, `'unsafe-inline'`], - styleSrc: [`'self'`, `'unsafe-inline'`], + scriptSrc: isProduction ? [`'self'`] : [`'self'`, `'unsafe-inline'`], + styleSrc: isProduction ? [`'self'`] : [`'self'`, `'unsafe-inline'`], imgSrc: [`'self'`, 'data:', 'https:'], fontSrc: [`'self'`, 'https:', 'data:'], }, @@ -39,11 +41,13 @@ async function bootstrap() { }), ); - // CORS — restrict to known origin; fallback keeps local dev working if unset - const allowedOrigin = - configService.get('ALLOWED_ORIGIN') ?? 'http://localhost:5173'; + // CORS — require ALLOWED_ORIGIN in production; fall back to localhost in dev + const allowedOrigin = configService.get('ALLOWED_ORIGIN')?.trim(); + if (!allowedOrigin && isProduction) { + throw new Error('Missing required environment variable: ALLOWED_ORIGIN'); + } app.enableCors({ - origin: allowedOrigin, + origin: allowedOrigin ?? 'http://localhost:5173', credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }); From 09743d90f754dd77c0623b4f80b6c952dccac386 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 01:37:58 -0400 Subject: [PATCH 5/8] fix: consolidate NODE_ENV access via ConfigService and harden Helmet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive isProduction from configService.get('NODE_ENV') so all env access goes through the validated ConfigModule rather than mixing process.env reads with ConfigService calls - Reuse isProduction for all three conditional checks (CSP, Swagger, startup log) — no more raw process.env.NODE_ENV in bootstrap - Add explicit frameguard: deny (X-Frame-Options: DENY) and hsts with 1-year max-age + includeSubDomains in production (disabled in dev) - Normalize empty-string ALLOWED_ORIGIN to undefined via || so a whitespace-only value gets the same fail-fast treatment as unset - Use get('PORT') and ?? so port is always a number --- backend/src/main.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/src/main.ts b/backend/src/main.ts index ea4cfdd..c182417 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -16,15 +16,16 @@ async function bootstrap() { // Application configuration const configService = app.get(ConfigService); - const port = configService.get('PORT') || 3001; - const appName = configService.get('APP_NAME') || 'STATION BACKEND'; - const isProduction = process.env.NODE_ENV === 'production'; + const port = configService.get('PORT') ?? 3001; + const appName = configService.get('APP_NAME') ?? 'STATION BACKEND'; + const isProduction = configService.get('NODE_ENV') === 'production'; // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); // 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: { @@ -38,11 +39,17 @@ async function bootstrap() { 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 - const allowedOrigin = configService.get('ALLOWED_ORIGIN')?.trim(); + // 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'); } @@ -65,7 +72,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( @@ -114,7 +121,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', From 5112e7bc6c7b2ee208ecb0c866429ab4ed405463 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 09:34:12 -0400 Subject: [PATCH 6/8] fix: validate NODE_ENV at startup to prevent misconfigured security posture Fail fast if NODE_ENV is missing or not one of production/development/test. Without this, an unset or mistyped NODE_ENV silently enables non-production security settings (relaxed CSP, Swagger exposed, HSTS disabled) even in production deployments. --- backend/src/main.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/src/main.ts b/backend/src/main.ts index c182417..38e712b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -18,7 +18,18 @@ async function bootstrap() { const configService = app.get(ConfigService); const port = configService.get('PORT') ?? 3001; const appName = configService.get('APP_NAME') ?? 'STATION BACKEND'; - const isProduction = configService.get('NODE_ENV') === 'production'; + + 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' })); From 4dcc685743fb7cb5715c3f2a3d1e52ae2596309d Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 09:42:48 -0400 Subject: [PATCH 7/8] docs: add NODE_ENV to .env.example with allowed values noted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required after startup validation was added — a developer copying .env.example to .env would otherwise hit the startup error immediately. --- backend/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/.env.example b/backend/.env.example index 2130a9c..5f190c7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,7 @@ REDIS_PORT=6379 USE_REDIS_CACHE=true # Application Configuration +NODE_ENV=development # Allowed values: development | production | test PORT=3001 APP_NAME=STATION BACKEND From 6e8bac993e80d93b957fe8c27ef9f36d98ed90b2 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 09:49:52 -0400 Subject: [PATCH 8/8] fix: move NODE_ENV comment to its own line in .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotenv doesn't strip inline comments — the value would have been parsed as "development # Allowed values: ..." which would fail the startup NODE_ENV validation. --- backend/.env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index 5f190c7..a22536e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,7 +14,8 @@ REDIS_PORT=6379 USE_REDIS_CACHE=true # Application Configuration -NODE_ENV=development # Allowed values: development | production | test +# Allowed values: development | production | test +NODE_ENV=development PORT=3001 APP_NAME=STATION BACKEND