diff --git a/package-lock.json b/package-lock.json index 64e63d14..f90db161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@opentelemetry/exporter-prometheus": "^0.203.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/sdk-node": "^0.203.0", + "@types/csurf": "^1.11.5", "@types/express-session": "^1.18.2", "@types/handlebars": "^4.0.40", "@types/multer": "^1.4.12", @@ -56,6 +57,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "connect-redis": "^9.0.0", + "csurf": "^1.11.0", "dataloader": "^2.2.3", "express": "^5.2.1", "express-session": "^1.19.0", @@ -7690,6 +7692,15 @@ "@types/node": "*" } }, + "node_modules/@types/csurf": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", + "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -10617,12 +10628,106 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cssfilter": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", "license": "MIT" }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/dargs": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", @@ -17694,6 +17799,12 @@ "node": "*" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -19465,6 +19576,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 96f1e39e..798cdaf5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@opentelemetry/exporter-prometheus": "^0.203.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/sdk-node": "^0.203.0", + "@types/csurf": "^1.11.5", "@types/express-session": "^1.18.2", "@types/handlebars": "^4.0.40", "@types/multer": "^1.4.12", @@ -76,6 +77,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "connect-redis": "^9.0.0", + "csurf": "^1.11.0", "dataloader": "^2.2.3", "express": "^5.2.1", "express-session": "^1.19.0", @@ -84,6 +86,7 @@ "graphql": "^16.12.0", "graphql-subscriptions": "^3.0.0", "handlebars": "^4.7.8", + "helmet": "^8.0.0", "ioredis": "^5.9.3", "joi": "^17.13.3", "multer": "^2.0.1", @@ -101,8 +104,7 @@ "stripe": "^18.3.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", - "uuid": "^11.1.0", - "helmet": "^8.0.0" + "uuid": "^11.1.0" }, "devDependencies": { "@commitlint/cli": "^19.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index ada3a7b3..8c8ac0f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -51,6 +51,7 @@ import { CdnModule } from './cdn/cdn.module'; import { AuthModule } from './auth/auth.module'; import { PaymentsModule } from './payments/payments.module'; import { LocalizationModule } from './localization/localization.module'; +import { CsrfModule } from './common/csrf/csrf.module'; @Module({}) export class AppModule { @@ -128,6 +129,7 @@ export class AppModule { ApiVersioningModule, HealthModule, DatabaseModule, + CsrfModule, ]; // Feature modules - conditionally loaded based on feature flags diff --git a/src/assessment/entities/assessment.entity.ts b/src/assessment/entities/assessment.entity.ts index fb8e9128..9ea98b84 100644 --- a/src/assessment/entities/assessment.entity.ts +++ b/src/assessment/entities/assessment.entity.ts @@ -8,6 +8,10 @@ import { Entity, OneToMany, PrimaryGeneratedColumn, + Index, + DeleteDateColumn, + OneToMany, + PrimaryGeneratedColumn, } from 'typeorm'; import { Question } from './question.entity'; diff --git a/src/assessment/entities/question.entity.ts b/src/assessment/entities/question.entity.ts index aff1d244..8469aad2 100644 --- a/src/assessment/entities/question.entity.ts +++ b/src/assessment/entities/question.entity.ts @@ -1,4 +1,4 @@ -import { ManyToOne, PrimaryGeneratedColumn, Index } from 'typeorm'; +import { Entity, ManyToOne, PrimaryGeneratedColumn, Index } from 'typeorm'; import { Column, DeleteDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { QuestionType } from '../enums/question-type.enum'; import { Assessment } from './assessment.entity'; diff --git a/src/common/csrf/csrf.controller.ts b/src/common/csrf/csrf.controller.ts new file mode 100644 index 00000000..97ffab44 --- /dev/null +++ b/src/common/csrf/csrf.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Get, Post, UseGuards, Req, Res, HttpStatus, HttpCode } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CsrfService } from './csrf.service'; + +@ApiTags('CSRF') +@Controller('csrf') +export class CsrfController { + constructor(private readonly csrfService: CsrfService) {} + + @Get('token') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get CSRF token' }) + @ApiResponse({ status: 200, description: 'CSRF token generated successfully' }) + getCsrfToken(@Req() req: Request, @Res() res: Response): void { + const sessionId = this.getSessionId(req); + const token = this.csrfService.generateToken(sessionId); + + res.setHeader('X-CSRF-Token', token); + res.json({ csrfToken: token }); + } + + @Post('validate') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Validate CSRF token' }) + @ApiResponse({ status: 200, description: 'CSRF token is valid' }) + @ApiResponse({ status: 400, description: 'Invalid CSRF token' }) + validateCsrfToken(@Req() req: Request): { valid: boolean } { + const sessionId = this.getSessionId(req); + const token = req.body?.csrfToken || req.headers['x-csrf-token']; + + const isValid = this.csrfService.validateToken(sessionId, token); + return { valid: isValid }; + } + + @Post('invalidate') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Invalidate CSRF token' }) + @ApiResponse({ status: 200, description: 'CSRF token invalidated successfully' }) + invalidateCsrfToken(@Req() req: Request): { message: string } { + const sessionId = this.getSessionId(req); + this.csrfService.invalidateToken(sessionId); + + return { message: 'CSRF token invalidated successfully' }; + } + + private getSessionId(req: Request): string { + if ((req as any).session?.id) { + return (req as any).session.id; + } + return req.ip || req.connection.remoteAddress || 'unknown'; + } +} diff --git a/src/common/csrf/csrf.module.ts b/src/common/csrf/csrf.module.ts new file mode 100644 index 00000000..2535efe1 --- /dev/null +++ b/src/common/csrf/csrf.module.ts @@ -0,0 +1,15 @@ +import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; +import { CsrfMiddleware } from '../middleware/csrf.middleware'; +import { CsrfService } from './csrf.service'; +import { CsrfController } from './csrf.controller'; + +@Module({ + providers: [CsrfService], + controllers: [CsrfController], + exports: [CsrfService], +}) +export class CsrfModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CsrfMiddleware).forRoutes({ path: '*', method: RequestMethod.ALL }); + } +} diff --git a/src/common/csrf/csrf.service.ts b/src/common/csrf/csrf.service.ts new file mode 100644 index 00000000..2f2ef0b4 --- /dev/null +++ b/src/common/csrf/csrf.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class CsrfService { + private readonly csrfTokens = new Map(); + private readonly tokenExpiryTime: number; + + constructor(private configService: ConfigService) { + this.tokenExpiryTime = this.configService.get('CSRF_TOKEN_EXPIRY', 3600000); // 1 hour default + } + + generateToken(sessionId: string): string { + const token = uuidv4(); + const expires = Date.now() + this.tokenExpiryTime; + + this.csrfTokens.set(sessionId, { token, expires }); + return token; + } + + validateToken(sessionId: string, token: string): boolean { + const storedToken = this.csrfTokens.get(sessionId); + + if (!storedToken || storedToken.expires <= Date.now()) { + return false; + } + + return storedToken.token === token; + } + + invalidateToken(sessionId: string): void { + this.csrfTokens.delete(sessionId); + } + + getToken(sessionId: string): string | null { + const storedToken = this.csrfTokens.get(sessionId); + + if (!storedToken || storedToken.expires <= Date.now()) { + return null; + } + + return storedToken.token; + } + + cleanupExpiredTokens(): void { + const now = Date.now(); + for (const [sessionId, tokenData] of this.csrfTokens.entries()) { + if (tokenData.expires <= now) { + this.csrfTokens.delete(sessionId); + } + } + } +} diff --git a/src/common/middleware/csrf.middleware.ts b/src/common/middleware/csrf.middleware.ts new file mode 100644 index 00000000..5638fd20 --- /dev/null +++ b/src/common/middleware/csrf.middleware.ts @@ -0,0 +1,65 @@ +import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { CsrfService } from '../csrf/csrf.service'; + +@Injectable() +export class CsrfMiddleware implements NestMiddleware { + constructor( + private csrfService: CsrfService, + private configService: ConfigService, + ) {} + + use(req: Request, res: Response, next: NextFunction): void { + // Skip CSRF for GET, HEAD, OPTIONS requests + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + this.generateCsrfToken(req, res); + return next(); + } + + // Validate CSRF token for state-changing requests + if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { + this.validateCsrfToken(req); + } + + next(); + } + + private generateCsrfToken(req: Request, res: Response): void { + const sessionId = this.getSessionId(req); + const existingToken = this.csrfService.getToken(sessionId); + + if (existingToken) { + res.setHeader('X-CSRF-Token', existingToken); + (req as any).csrfToken = existingToken; + return; + } + + // Generate new token + const token = this.csrfService.generateToken(sessionId); + res.setHeader('X-CSRF-Token', token); + (req as any).csrfToken = token; + } + + private validateCsrfToken(req: Request): void { + const sessionId = this.getSessionId(req); + + const tokenFromHeader = req.headers['x-csrf-token'] as string; + const tokenFromBody = req.body?._csrf; + const submittedToken = tokenFromHeader || tokenFromBody; + + if (!submittedToken || !this.csrfService.validateToken(sessionId, submittedToken)) { + throw new UnauthorizedException('Invalid CSRF token'); + } + } + + private getSessionId(req: Request): string { + // Try to get session ID from session + if ((req as any).session?.id) { + return (req as any).session.id; + } + + // Fallback to IP address (less secure, but better than nothing) + return req.ip || req.connection.remoteAddress || 'unknown'; + } +} diff --git a/src/main.ts b/src/main.ts index c1f10a18..73a8009f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,15 @@ async function bootstrapWorker() { includeSubDomains: true, preload: true, }, + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, }), );