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",
|
"form-data": "^4.0.4",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.8.0",
|
"ioredis": "^5.8.0",
|
||||||
"langchain": "^0.3.35",
|
"langchain": "^0.3.35",
|
||||||
"mailgun.js": "^12.1.0",
|
"mailgun.js": "^12.1.0",
|
||||||
@@ -10172,6 +10173,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.4",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.8.0",
|
"ioredis": "^5.8.0",
|
||||||
"langchain": "^0.3.35",
|
"langchain": "^0.3.35",
|
||||||
"mailgun.js": "^12.1.0",
|
"mailgun.js": "^12.1.0",
|
||||||
|
|||||||
@@ -1,30 +1,94 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
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({
|
app.enableCors({
|
||||||
origin: process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()) || [
|
origin: (origin, callback) => {
|
||||||
'http://localhost:19000',
|
// Allow requests with no origin (mobile apps, Postman, etc.)
|
||||||
'http://localhost:3001',
|
if (!origin) {
|
||||||
'http://localhost:3030',
|
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'],
|
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,
|
credentials: true,
|
||||||
preflightContinue: false,
|
preflightContinue: false,
|
||||||
optionsSuccessStatus: 204,
|
optionsSuccessStatus: 204,
|
||||||
|
maxAge: 86400, // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global validation pipe
|
// Global validation pipe with comprehensive settings
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true, // Strip properties that don't have decorators
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true, // Throw error if non-whitelisted values are provided
|
||||||
transform: true,
|
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(`🚀 Backend API running on http://0.0.0.0:${port}`);
|
||||||
console.log(`📚 API Base: http://0.0.0.0:${port}/api/v1`);
|
console.log(`📚 API Base: http://0.0.0.0:${port}/api/v1`);
|
||||||
|
console.log(`🔒 Security: Helmet enabled, CORS configured for ${allowedOrigins.length} origins`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
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 {
|
export class ChatMessageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@IsNotEmpty()
|
||||||
@MaxLength(2000)
|
@MinLength(1, { message: 'Message cannot be empty' })
|
||||||
|
@MaxLength(2000, { message: 'Message cannot exceed 2000 characters' })
|
||||||
|
@Transform(({ value }) => value?.trim()) // Trim whitespace
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Matches(/^conv_[a-z0-9]{16}$/, {
|
||||||
|
message: 'Invalid conversation ID format',
|
||||||
|
})
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,21 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
IsArray,
|
IsArray,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsEmail,
|
||||||
|
ArrayMaxSize,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import { FeedbackType, FeedbackSentiment } from '../feedback.entity';
|
import { FeedbackType, FeedbackSentiment } from '../feedback.entity';
|
||||||
|
|
||||||
export class CreateFeedbackDto {
|
export class CreateFeedbackDto {
|
||||||
@IsEnum(FeedbackType)
|
@IsEnum(FeedbackType, { message: 'Invalid feedback type' })
|
||||||
type: FeedbackType;
|
type: FeedbackType;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(5000)
|
@IsNotEmpty({ message: 'Message cannot be empty' })
|
||||||
|
@MaxLength(5000, { message: 'Message cannot exceed 5000 characters' })
|
||||||
|
@Transform(({ value }) => value?.trim())
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -52,10 +58,12 @@ export class CreateFeedbackDto {
|
|||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ArrayMaxSize(10, { message: 'Cannot have more than 10 tags' })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEmail({}, { message: 'Invalid email format' })
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
Reference in New Issue
Block a user