feat: Add comprehensive security hardening with Helmet and strict CORS
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:
10
maternal-app/maternal-app-backend/package-lock.json
generated
10
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user