# Security Review Checklist ## Input Validation & Sanitization - [ ] All user inputs are validated before use - [ ] Input validation happens on both client and server - [ ] Special characters and potential payloads are properly escaped - [ ] File uploads have type and size restrictions - [ ] Query parameters are validated for expected types ```javascript // ❌ BAD - No validation app.post('/user', (req, res) => { const email = req.body.email; db.query(`INSERT INTO users VALUES ('${email}')`); }); // ✅ GOOD - Validated & parameterized app.post('/user', (req, res) => { const email = req.body.email; if (!isValidEmail(email)) throw new Error('Invalid email'); db.query('INSERT INTO users VALUES (?)', [email]); }); ``` ## SQL Injection Prevention - [ ] Using parameterized queries/prepared statements - [ ] No string concatenation in SQL queries - [ ] ORM used correctly (Sequelize, TypeORM, etc.) - [ ] Dynamic query building uses safe methods ## NoSQL Injection Prevention - [ ] All query inputs coerced to primitives before passing to Mongoose/MongoDB - [ ] No object/array passed directly from `req.body` into a Mongoose query filter - [ ] `mongoose.Schema` `strict` mode is ON (default) — never disabled - [ ] `$where` operator is prohibited — executes arbitrary JavaScript on the server - [ ] Mongoose schema types enforce field types (not just application-level validation) - [ ] `express-mongo-sanitize` middleware used to strip `$` and `.` from request inputs - [ ] Prototype pollution vectors guarded (no `JSON.parse` + direct merge without sanitisation) ```typescript // ❌ CRITICAL — operator injection // req.body = { email: { "$gt": "" } } → returns ALL users const user = await this.userModel.findOne({ email: req.body.email }); // ✅ GOOD — coerce to primitive const email = String(req.body.email); const user = await this.userModel.findOne({ email }); // ✅ GOOD — use class-validator DTO + ValidationPipe (NestJS) — rejects non-string at boundary export class LoginDto { @IsEmail() email: string; @IsString() @MinLength(8) password: string; } // ✅ GOOD — express-mongo-sanitize as global middleware (Express/NestJS) import mongoSanitize from 'express-mongo-sanitize'; app.use(mongoSanitize()); // strips '$' and '.' keys from req.body, req.query, req.params // ❌ CRITICAL — $where executes JavaScript server-side await this.userModel.find({ $where: `this.email == '${email}'` }); // remote code execution risk // ✅ GOOD — never use $where; use standard query operators await this.userModel.find({ email }); ``` ## Authentication & Authorization - [ ] Passwords are hashed (bcrypt, Argon2) - [ ] Authentication tokens expire - [ ] JWT tokens are validated and not tampered with - [ ] Password reset tokens are time-limited - [ ] Sessions are invalidated on logout - [ ] API endpoints check user permissions - [ ] Role-based access control (RBAC) is implemented ## XSS (Cross-Site Scripting) Prevention - [ ] User input is escaped before rendering - [ ] Content Security Policy (CSP) headers are set - [ ] No eval() or innerHTML with user content - [ ] Template engines auto-escape by default ```javascript // ❌ BAD res.send(`

${userInput}

`); // ✅ GOOD - Using template engine with auto-escape res.render('page', { title: userInput }); ``` ## CSRF (Cross-Site Request Forgery) Protection - [ ] CSRF tokens are generated and validated - [ ] Same-Site cookie attribute is set - [ ] Sensitive operations use POST, not GET - [ ] Cross-origin requests are properly validated ## Data Protection - [ ] Sensitive data (passwords, tokens, API keys) not logged - [ ] Database passwords not hardcoded - [ ] API keys and secrets use environment variables - [ ] Sensitive data is encrypted at rest - [ ] Data in transit uses HTTPS/TLS - [ ] PII (Personally Identifiable Information) handling is compliant ## Dependency Security - [ ] No known vulnerabilities in dependencies - [ ] Dependencies are regularly updated - [ ] npm audit passes (or issues are documented) - [ ] Only necessary dependencies are included - [ ] Supply chain security is considered ## Error Handling - [ ] Error messages don't expose sensitive information - [ ] Stack traces not returned to client - [ ] Database errors are caught and logged - [ ] Errors are logged with enough context ```javascript // ❌ BAD - Exposing sensitive info res.status(500).json({ error: error.message }); // ✅ GOOD - Generic error for client res.status(500).json({ error: 'Internal server error' }); logger.error(error); // Log full error internally ``` ## API Security - [ ] Rate limiting is implemented - [ ] API endpoints require authentication - [ ] CORS is properly configured - [ ] API versioning is used - [ ] Request size limits are enforced - [ ] Timeout limits are set ## Security Headers - [ ] X-Content-Type-Options: nosniff - [ ] X-Frame-Options: DENY or SAMEORIGIN - [ ] X-XSS-Protection: 1; mode=block - [ ] Strict-Transport-Security (HSTS) - [ ] Content-Security-Policy (CSP) - [ ] Referrer-Policy ## File Upload Security - [ ] File types are validated - [ ] File size limits are enforced - [ ] Uploaded files are stored outside web root - [ ] Uploaded files are scanned for malware - [ ] Filenames are sanitized - [ ] MIME types are verified ## Credential Management - [ ] OAuth2 tokens are properly stored - [ ] Refresh tokens are rotated - [ ] Credentials are never logged - [ ] API keys are rotated regularly - [ ] Secrets are not in version control ## MongoDB-Specific Security ```javascript // ❌ BAD - Operator injection db.collection('users').find({ username: req.body.username }); // ✅ GOOD - Properly handled const username = String(req.body.username); db.collection('users').find({ username: username }); ``` - [ ] Operators like $where, $function are avoided - [ ] User input in queries is properly typed - [ ] Aggregation pipelines validate input - [ ] Server-side JavaScript execution is disabled ## Logging & Monitoring - [ ] Security events are logged - [ ] Failed authentication attempts are logged - [ ] Logs don't contain sensitive data - [ ] Logs are retained appropriately - [ ] Monitoring for suspicious activity is in place --- **Severity Levels**: - 🔴 **Critical**: Can lead to data breach or unauthorized access - 🟠 **High**: Significant security risk - 🟡 **Medium**: Should be addressed - 🟢 **Low**: Best practice recommendation --- ## 🔷 TypeScript / NestJS Security Patterns > The following section covers security patterns specific to NestJS and TypeScript. ### NestJS-Specific Security Checklist - [ ] `ValidationPipe` configured globally with `whitelist: true` (prevents mass assignment) - [ ] `ValidationPipe` configured with `forbidNonWhitelisted: true` (rejects unknown fields) - [ ] All DTOs use `class-validator` decorators (no raw `body: any`) - [ ] Auth guards applied to ALL sensitive routes (`@UseGuards(JwtAuthGuard)`) - [ ] Role guards applied to privileged routes (`@UseGuards(RolesGuard)`) - [ ] `@Roles()` decorator matches the actual business requirement - [ ] `ThrottlerGuard` applied to auth endpoints - [ ] Global exception filter registered (no raw errors leaking to client) - [ ] `@Exclude()` or `select: false` on sensitive entity columns (passwordHash, etc.) - [ ] Response DTOs used — entity never returned directly from controller - [ ] `ConfigModule` with Joi validation — app fails at startup if secrets missing - [ ] `helmet` middleware applied in `main.ts` - [ ] CORS configured explicitly (not `origin: '*'`) ### Mass Assignment Prevention ```typescript // ❌ CRITICAL — user can send { role: 'admin' } and elevate their own privileges @Post('/users') async createUser(@Body() body: any) { return this.userRepository.save(body); // takes every field from request! } // ✅ GOOD — ValidationPipe whitelist + explicit DTO // In main.ts: app.useGlobalPipes(new ValidationPipe({ whitelist: true, // strips any field not in DTO forbidNonWhitelisted: true, // throws 400 if unknown fields sent transform: true, })); // DTO explicitly defines what's allowed: export class CreateUserDto { @IsEmail() email: string; @IsString() @MinLength(8) password: string; // 'role' is NOT here — can't be set by user } // Service sets role server-side: async create(dto: CreateUserDto): Promise { const user = this.userRepository.create({ ...dto, role: UserRole.USER, // always server-set passwordHash: await bcrypt.hash(dto.password, 12), }); return this.userRepository.save(user); } ``` ### NestJS JWT Security ```typescript // ✅ GOOD — NestJS JWT strategy with full validation @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private readonly configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, // NEVER ignore expiry secretOrKey: configService.get('JWT_SECRET'), algorithms: ['HS256'], // explicit algorithm }); } async validate(payload: JwtPayload): Promise { // Always fetch fresh user — token might be for a deleted/banned user const user = await this.userService.findById(payload.sub); if (!user || !user.isActive) { throw new UnauthorizedException('User not found or inactive'); } return user; } } // ❌ BAD — trusting JWT payload without DB check async validate(payload: JwtPayload) { return { id: payload.sub, email: payload.email }; // stale data; user might be deleted } ``` ### NestJS Rate Limiting ```typescript // ✅ GOOD — rate limiting on auth endpoints // main.ts app.useGlobalGuards(new ThrottlerGuard()); // app.module.ts @Module({ imports: [ ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, // 1 minute limit: 100, // 100 requests per minute globally }]), ], }) // Stricter on auth: @Controller('/auth') export class AuthController { @Post('/login') @Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 per minute on login async login(@Body() dto: LoginDto): Promise { ... } } ``` ### Helmet Security Headers (Required in main.ts) ```typescript // ✅ GOOD — security headers via helmet import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'"], imgSrc: ["'self'", 'data:', 'https:'], }, }, hsts: { maxAge: 31536000, // 1 year includeSubDomains: true, preload: true, }, })); // CORS — explicit allowlist only app.enableCors({ origin: process.env.CORS_ALLOWED_ORIGINS?.split(',') ?? [], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], credentials: true, }); } ``` ### Sensitive Data in Response DTOs ```typescript // ❌ CRITICAL — returns entity directly including passwordHash @Get('/me') async getMe(@CurrentUser() user: User): Promise { return user; // sends passwordHash, refreshToken, etc. to client! } // ✅ GOOD — map to safe DTO export class UserResponseDto { @Expose() id: string; @Expose() email: string; @Expose() name: string; @Expose() role: UserRole; @Expose() createdAt: Date; // passwordHash, refreshToken — NOT exposed } @Get('/me') async getMe(@CurrentUser() user: User): Promise { return plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true }); } ``` ### SQL Injection with TypeORM QueryBuilder ```typescript // ❌ CRITICAL — template literal in QueryBuilder = SQL injection async searchUsers(searchTerm: string): Promise { return this.userRepository .createQueryBuilder('user') .where(`user.name LIKE '%${searchTerm}%'`) // SQL injection! .getMany(); } // ✅ GOOD — parameterized binding async searchUsers(searchTerm: string): Promise { return this.userRepository .createQueryBuilder('user') .where('user.name ILIKE :term', { term: `%${searchTerm}%` }) // safe .getMany(); } ``` ### Environment Variable Security (NestJS ConfigModule) ```typescript // ✅ GOOD — validate all secrets at startup, fail fast @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), DATABASE_URL: Joi.string().uri().required(), JWT_SECRET: Joi.string().min(32).required(), // enforce minimum length JWT_EXPIRES_IN: Joi.string().default('1h'), BCRYPT_ROUNDS: Joi.number().min(10).default(12), CORS_ALLOWED_ORIGINS: Joi.string().required(), STRIPE_SECRET_KEY: Joi.string().pattern(/^sk_(live|test)_/).required(), }), }), ], }) export class AppModule {} // If any variable is missing/invalid → startup fails → never reaches production ``` --- ## Mongoose-Specific Security Reference > This section is a first-class checklist because Mongoose is the primary ODM. > Each item below is a flag the principal engineering board will raise in review. ### SEC-M1: Query Injection via Unsanitised Object Input ```typescript // ❌ CRITICAL — attacker sends { "password": { "$gt": "" } } const user = await this.userModel.findOne({ email: req.body.email, password: req.body.password, // operator injection bypasses password check entirely }); // ✅ GOOD — DTO boundary enforces primitive types export class LoginDto { @IsEmail() email: string; @IsString() password: string; } // ValidationPipe at the controller ensures both fields are strings before they reach the service ``` --- ### SEC-M2: `strict: false` — Never Acceptable ```typescript // ❌ CRITICAL — disabling strict allows any key from req.body to be stored in MongoDB @Schema({ strict: false }) export class User { ... } // Attacker can inject role, isAdmin, or any field not in your schema // ✅ GOOD — strict is ON by default; keep it that way // If you need flexible fields, define them explicitly: @Prop({ type: Map, of: mongoose.Schema.Types.Mixed }) metadata: Map; ``` --- ### SEC-M3: `select: false` — Defense in Depth for Sensitive Fields ```typescript // ❌ BAD — password hash always returned unless caller explicitly excludes it @Prop({ required: true }) password: string; // ✅ GOOD — excluded by default; must be explicitly opted in with .select('+password') @Prop({ required: true, select: false }) password: string; // ✅ GOOD — belt + suspenders in repository: even if schema is misconfigured, never return it async findByEmail(email: string): Promise { return this.userModel .findOne({ email }) .select('-password -__v') // explicit exclusion regardless of schema default .lean() .exec(); } // ✅ GOOD — only select password when you need to verify it (login flow) async findByEmailWithPassword(email: string): Promise { return this.userModel .findOne({ email }) .select('+password') // explicitly opt in — makes the intent obvious in code review .exec(); } ``` --- ### SEC-M4: Prototype Pollution via `$`-key Inputs ```typescript // ❌ BAD — merging unvalidated object into a query or model const filter = { ...req.query }; // req.query could contain { __proto__: { isAdmin: true } } await this.userModel.find(filter); // ✅ GOOD — never spread unvalidated external input into a Mongoose query // Always transform through a DTO or explicit field extraction: const { email, role } = req.query as { email?: string; role?: string }; await this.userModel.find({ ...(email ? { email: String(email) } : {}), ...(role ? { role: String(role) } : {}), }); // ✅ GOOD — use express-mongo-sanitize globally to strip prototype keys import mongoSanitize from 'express-mongo-sanitize'; app.use(mongoSanitize({ replaceWith: '_' })); ``` --- ### SEC-M5: Population of Sensitive Referenced Documents ```typescript // ❌ CRITICAL — populate without select exposes the entire referenced document const order = await this.orderModel .findById(id) .populate('userId') // returns full User including passwordHash, tokens, etc. .exec(); // ✅ GOOD — always restrict what populated documents expose const order = await this.orderModel .findById(id) .populate<{ userId: Pick }>({ path: 'userId', select: 'email name', // only what the consumer actually needs }) .lean() .exec(); ``` --- ### SEC-M6: Mass Assignment via `new Model(req.body)` ```typescript // ❌ CRITICAL — attacker can set role, isAdmin, balance, or any schema field const user = new this.userModel(req.body); await user.save(); // ✅ GOOD — map explicitly from a validated DTO async create(dto: CreateUserDto): Promise { const user = new this.userModel({ email: dto.email, name: dto.name, // role is NOT accepted from the client — hardcoded to default role: UserRole.USER, }); return user.save(); } ``` --- ### SEC-M7: CastError Leaks Internal Schema Info ```typescript // ❌ BAD — Mongoose CastError message reveals schema field names and types // Error: Cast to ObjectId failed for value "abc" at path "_id" // This tells an attacker your ID field type and structure // ✅ GOOD — catch CastErrors and translate to generic 400/404 import { Error as MongooseError } from 'mongoose'; // In a global exception filter: if (exception instanceof MongooseError.CastError) { return res.status(400).json({ message: 'Invalid identifier format' }); } // ✅ GOOD — validate ObjectId before it reaches Mongoose (preferred — fail fast) @Injectable() export class ParseObjectIdPipe implements PipeTransform { transform(value: string): string { if (!mongoose.isValidObjectId(value)) { throw new BadRequestException('Invalid identifier format'); } return value; } } ``` --- ### Mongoose Security Pre-Merge Checklist ``` □ No req.body / req.query passed directly into a Mongoose filter without DTO validation □ express-mongo-sanitize (or equivalent) applied as global middleware □ No $where operator anywhere in the codebase □ strict: false not present on any schema □ Sensitive fields (password, tokens, secrets) have select: false AND are excluded in repos □ All populate() calls have an explicit select field list □ new Model(req.body) pattern is absent — always explicit field mapping from DTO □ CastErrors are caught and translated — no Mongoose internals leaked to clients □ All ObjectId params validated before reaching the service/repository layer ``` --- **Last Updated**: 2026-06-26 **Covers**: NestJS · TypeScript · Express · Node.js Security Patterns · Mongoose Security