diff --git a/maternal-app/maternal-app-backend/package-lock.json b/maternal-app/maternal-app-backend/package-lock.json index 2707645..18449ca 100644 --- a/maternal-app/maternal-app-backend/package-lock.json +++ b/maternal-app/maternal-app-backend/package-lock.json @@ -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", diff --git a/maternal-app/maternal-app-backend/package.json b/maternal-app/maternal-app-backend/package.json index 61e0570..fc8ce2f 100644 --- a/maternal-app/maternal-app-backend/package.json +++ b/maternal-app/maternal-app-backend/package.json @@ -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", diff --git a/maternal-app/maternal-app-backend/src/main.ts b/maternal-app/maternal-app-backend/src/main.ts index 2cef01e..49b6389 100644 --- a/maternal-app/maternal-app-backend/src/main.ts +++ b/maternal-app/maternal-app-backend/src/main.ts @@ -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(); diff --git a/maternal-app/maternal-app-backend/src/modules/ai/dto/chat-message.dto.ts b/maternal-app/maternal-app-backend/src/modules/ai/dto/chat-message.dto.ts index 388d0b1..36cdc7c 100644 --- a/maternal-app/maternal-app-backend/src/modules/ai/dto/chat-message.dto.ts +++ b/maternal-app/maternal-app-backend/src/modules/ai/dto/chat-message.dto.ts @@ -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; } diff --git a/maternal-app/maternal-app-backend/src/modules/feedback/dto/create-feedback.dto.ts b/maternal-app/maternal-app-backend/src/modules/feedback/dto/create-feedback.dto.ts index fce7541..af68c35 100644 --- a/maternal-app/maternal-app-backend/src/modules/feedback/dto/create-feedback.dto.ts +++ b/maternal-app/maternal-app-backend/src/modules/feedback/dto/create-feedback.dto.ts @@ -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()