feat: Add comprehensive security hardening with Helmet and strict CORS
Some checks failed
CI/CD Pipeline / Build Application (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled

Security Features Implemented:
- Helmet.js with Content Security Policy (CSP)
  - Allows GraphQL Playground ('unsafe-inline', 'unsafe-eval')
  - Strict default-src, object-src 'none', frame-src 'none'
- HSTS with 1-year max-age and subdomain inclusion
- X-Frame-Options: DENY
- X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin

CORS Configuration:
- Strict origin whitelisting (localhost:19000, 3001, 3030)
- Origin validation callback with logging
- Allows no-origin requests (mobile apps)
- Blocks unauthorized origins with error

Input Validation Enhancements:
- Global ValidationPipe with whitelist mode
- Strips non-decorated properties (whitelist: true)
- Throws error for unknown properties (forbidNonWhitelisted: true)
- Hides validation errors in production
- Enhanced DTOs with Transform decorators and regex validation

Testing Verified:
 All security headers present in responses
 CORS blocks unauthorized origins
 CORS allows whitelisted origins
 Backend compiles with 0 errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-03 07:17:31 +00:00
parent d14b461fb2
commit 2bb7a2d512
5 changed files with 106 additions and 16 deletions

View File

@@ -47,6 +47,7 @@
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"graphql-type-json": "^0.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",
@@ -10172,6 +10173,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",

View File

@@ -59,6 +59,7 @@
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"graphql-type-json": "^0.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",

View File

@@ -1,30 +1,94 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS
// Security headers with Helmet
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // GraphQL Playground needs unsafe-inline
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // GraphQL Playground needs unsafe-eval
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
crossOriginEmbedderPolicy: false, // Disable for compatibility
crossOriginResourcePolicy: { policy: 'cross-origin' },
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
frameguard: {
action: 'deny',
},
noSniff: true,
xssFilter: true,
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
}),
);
// Strict CORS configuration
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()) || [
'http://localhost:19000', // Expo dev
'http://localhost:3001', // Next.js dev (legacy)
'http://localhost:3030', // Next.js dev (current)
];
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()) || [
'http://localhost:19000',
'http://localhost:3001',
'http://localhost:3030',
],
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
console.warn(`CORS blocked origin: ${origin}`);
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'apollo-require-preflight'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Requested-With',
'apollo-require-preflight',
],
exposedHeaders: ['Content-Range', 'X-Content-Range'],
credentials: true,
preflightContinue: false,
optionsSuccessStatus: 204,
maxAge: 86400, // 24 hours
});
// Global validation pipe
// Global validation pipe with comprehensive settings
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
whitelist: true, // Strip properties that don't have decorators
forbidNonWhitelisted: true, // Throw error if non-whitelisted values are provided
transform: true, // Automatically transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true, // Convert primitive types automatically
},
disableErrorMessages: process.env.NODE_ENV === 'production', // Hide validation error details in production
validationError: {
target: false, // Don't expose the target object in errors
value: false, // Don't expose the value in errors
},
}),
);
@@ -33,5 +97,6 @@ async function bootstrap() {
console.log(`🚀 Backend API running on http://0.0.0.0:${port}`);
console.log(`📚 API Base: http://0.0.0.0:${port}/api/v1`);
console.log(`🔒 Security: Helmet enabled, CORS configured for ${allowedOrigins.length} origins`);
}
bootstrap();

View File

@@ -1,12 +1,18 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { IsString, IsOptional, MinLength, MaxLength, IsNotEmpty, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
export class ChatMessageDto {
@IsString()
@MinLength(1)
@MaxLength(2000)
@IsNotEmpty()
@MinLength(1, { message: 'Message cannot be empty' })
@MaxLength(2000, { message: 'Message cannot exceed 2000 characters' })
@Transform(({ value }) => value?.trim()) // Trim whitespace
message: string;
@IsOptional()
@IsString()
@Matches(/^conv_[a-z0-9]{16}$/, {
message: 'Invalid conversation ID format',
})
conversationId?: string;
}

View File

@@ -6,15 +6,21 @@ import {
IsObject,
IsArray,
MaxLength,
IsNotEmpty,
IsEmail,
ArrayMaxSize,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { FeedbackType, FeedbackSentiment } from '../feedback.entity';
export class CreateFeedbackDto {
@IsEnum(FeedbackType)
@IsEnum(FeedbackType, { message: 'Invalid feedback type' })
type: FeedbackType;
@IsString()
@MaxLength(5000)
@IsNotEmpty({ message: 'Message cannot be empty' })
@MaxLength(5000, { message: 'Message cannot exceed 5000 characters' })
@Transform(({ value }) => value?.trim())
message: string;
@IsString()
@@ -52,10 +58,12 @@ export class CreateFeedbackDto {
@IsArray()
@IsOptional()
@ArrayMaxSize(10, { message: 'Cannot have more than 10 tags' })
tags?: string[];
@IsString()
@IsOptional()
@IsEmail({}, { message: 'Invalid email format' })
userEmail?: string;
@IsBoolean()