diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5da1b31..f99dc9b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,3 +1,4 @@ +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -16,6 +17,12 @@ import { PuzzlesModule } from './puzzles/puzzles.module'; import { QuestsModule } from './quests/quests.module'; import { StreakModule } from './streak/strerak.module'; import { CategoriesModule } from './categories/categories.module'; +import { TransactionMiddleware } from './middleware/transaction/transaction.middleware'; +import { TransactionLogger } from './middleware/transaction/transaction.logger'; +import { CompressionMiddleware } from './middleware/compression/compression.middleware'; +import { IdempotencyMiddleware } from './middleware/idempotency/idempotency.middleware'; +import { IdempotencyService } from './middleware/idempotency/idempotency.service'; +import { SecurityHeadersMiddleware } from './middleware/security/security-headers.middleware'; import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.module'; import { REDIS_CLIENT } from './redis/redis.constants'; import jwtConfig from './auth/authConfig/jwt.config'; @@ -87,7 +94,6 @@ import { HealthModule } from './health/health.module'; CommonModule, RedisModule, BlockchainModule, - ProgressModule, CategoriesModule, // Register the custom JWT Auth Middleware module JwtAuthModule.registerAsync({ @@ -104,10 +110,19 @@ import { HealthModule } from './health/health.module'; HealthModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, TransactionLogger], }) export class AppModule implements NestModule { - /** + configure(consumer: MiddlewareConsumer) { + // Apply transaction middleware globally + consumer.apply(TransactionMiddleware).forRoutes('*'); + + + consumer.apply(CompressionMiddleware).forRoutes('*'); + consumer.apply(IdempotencyMiddleware).forRoutes('*'); + consumer.apply(SecurityHeadersMiddleware).forRoutes('*'); + +/** * Apply the JWT Authentication Middleware to all routes except public ones. */ configure(consumer: MiddlewareConsumer) { @@ -124,5 +139,6 @@ export class AppModule implements NestModule { { path: 'health', method: RequestMethod.GET }, ) .forRoutes('*'); + } } diff --git a/backend/src/common/controllers/security.controller.ts b/backend/src/common/controllers/security.controller.ts new file mode 100644 index 0000000..e527283 --- /dev/null +++ b/backend/src/common/controllers/security.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { Response } from 'express'; + +@Controller('.well-known') +export class SecurityController { + @Get('security.txt') + getSecurityTxt(@Res() res: Response) { + res.type('text/plain').send( + `Contact: security@yourdomain.com +Policy: https://yourdomain.com/security-policy +Acknowledgments: https://yourdomain.com/security-acknowledgments` + ); + } +} diff --git a/backend/src/middleware/compression/compression.config.ts b/backend/src/middleware/compression/compression.config.ts new file mode 100644 index 0000000..8dfa33c --- /dev/null +++ b/backend/src/middleware/compression/compression.config.ts @@ -0,0 +1,20 @@ +export const COMPRESSION_CONFIG = { + threshold: 1024, // minimum size in bytes + gzip: { level: 6 }, // balance speed vs size + brotli: { quality: 4 }, // modern browsers + skipTypes: [ + /^image\//, + /^video\//, + /^audio\//, + /^application\/zip/, + /^application\/gzip/, + ], + compressibleTypes: [ + 'application/json', + 'text/html', + 'text/plain', + 'application/javascript', + 'text/css', + 'text/xml', + ], +}; diff --git a/backend/src/middleware/compression/compression.middleware.ts b/backend/src/middleware/compression/compression.middleware.ts new file mode 100644 index 0000000..c2c5081 --- /dev/null +++ b/backend/src/middleware/compression/compression.middleware.ts @@ -0,0 +1,63 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import * as zlib from 'zlib'; +import { COMPRESSION_CONFIG } from './compression.config'; + +@Injectable() +export class CompressionMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const acceptEncoding = req.headers['accept-encoding'] || ''; + const chunks: Buffer[] = []; + const originalWrite = res.write; + const originalEnd = res.end; + + // Intercept response body + res.write = function (chunk: any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return true; + }; + + res.end = function (chunk: any) { + if (chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + const body = Buffer.concat(chunks); + + // Skip compression if too small + if (body.length < COMPRESSION_CONFIG.threshold) { + return originalEnd.call(res, body); + } + + const contentType = res.getHeader('Content-Type') as string; + if (COMPRESSION_CONFIG.skipTypes.some((regex) => regex.test(contentType))) { + return originalEnd.call(res, body); + } + + // Select algorithm + if (/\bbr\b/.test(acceptEncoding)) { + zlib.brotliCompress(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: COMPRESSION_CONFIG.brotli.quality } }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'br'); + originalEnd.call(res, compressed); + }); + } else if (/\bgzip\b/.test(acceptEncoding)) { + zlib.gzip(body, { level: COMPRESSION_CONFIG.gzip.level }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'gzip'); + originalEnd.call(res, compressed); + }); + } else if (/\bdeflate\b/.test(acceptEncoding)) { + zlib.deflate(body, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'deflate'); + originalEnd.call(res, compressed); + }); + } else { + return originalEnd.call(res, body); + } + }; + + next(); + } +} diff --git a/backend/src/middleware/idempotency/idempotency.config.ts b/backend/src/middleware/idempotency/idempotency.config.ts new file mode 100644 index 0000000..df590c1 --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.config.ts @@ -0,0 +1,9 @@ +export const IDEMPOTENCY_CONFIG = { + ttl: { + puzzleSubmission: 300, // 5 minutes + pointClaim: 600, // 10 minutes + friendRequest: 3600, // 1 hour + profileUpdate: 60, // 1 minute + }, + headerKey: 'x-idempotency-key', +}; diff --git a/backend/src/middleware/idempotency/idempotency.middleware.ts b/backend/src/middleware/idempotency/idempotency.middleware.ts new file mode 100644 index 0000000..fa7e3cc --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.middleware.ts @@ -0,0 +1,56 @@ +import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { IdempotencyService } from './idempotency.service'; +import { IDEMPOTENCY_CONFIG } from './idempotency.config'; + +@Injectable() +export class IdempotencyMiddleware implements NestMiddleware { + constructor(private readonly idempotencyService: IdempotencyService) {} + + async use(req: Request, res: Response, next: NextFunction) { + // Skip GET requests + if (req.method === 'GET') return next(); + + const headerKey = IDEMPOTENCY_CONFIG.headerKey; + let idempotencyKey = req.headers[headerKey] as string; + + if (!idempotencyKey) { + // Auto-generate key if not provided + idempotencyKey = await this.idempotencyService.generateKey(req); + } + + if (typeof idempotencyKey !== 'string') { + throw new BadRequestException('Invalid idempotency key format'); + } + + const cachedResponse = await this.idempotencyService.getResponse(idempotencyKey); + if (cachedResponse) { + // Return cached response immediately + res.set(cachedResponse.headers); + return res.status(cachedResponse.statusCode).send(cachedResponse.body); + } + + // Intercept response to store it + const originalSend = res.send.bind(res); + res.send = async (body: any) => { + const ttl = this.resolveTTL(req.originalUrl); + const responsePayload = { + statusCode: res.statusCode, + headers: res.getHeaders(), + body, + }; + await this.idempotencyService.storeResponse(idempotencyKey, responsePayload, ttl); + return originalSend(body); + }; + + next(); + } + + private resolveTTL(url: string): number { + if (url.includes('/puzzles')) return IDEMPOTENCY_CONFIG.ttl.puzzleSubmission; + if (url.includes('/points')) return IDEMPOTENCY_CONFIG.ttl.pointClaim; + if (url.includes('/friends')) return IDEMPOTENCY_CONFIG.ttl.friendRequest; + if (url.includes('/profile')) return IDEMPOTENCY_CONFIG.ttl.profileUpdate; + return 300; // default + } +} diff --git a/backend/src/middleware/idempotency/idempotency.service.ts b/backend/src/middleware/idempotency/idempotency.service.ts new file mode 100644 index 0000000..fc2d410 --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from '../../redis/redis.service'; +import * as crypto from 'crypto'; + +@Injectable() +export class IdempotencyService { + constructor(private readonly redisService: RedisService) {} + + async generateKey(req: any): Promise { + const userId = req.user?.id || 'anon'; + const bodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex'); + return `${userId}:${req.method}:${req.originalUrl}:${bodyHash}`; + } + + async storeResponse(key: string, response: any, ttl: number) { + const client = this.redisService.getClient(); + await client.set(key, JSON.stringify(response), 'EX', ttl, 'NX'); // SETNX for atomicity + } + + async getResponse(key: string): Promise { + const client = this.redisService.getClient(); + const data = await client.get(key); + return data ? JSON.parse(data) : null; + } +} diff --git a/backend/src/middleware/security/security-headers.config.ts b/backend/src/middleware/security/security-headers.config.ts new file mode 100644 index 0000000..cc97f87 --- /dev/null +++ b/backend/src/middleware/security/security-headers.config.ts @@ -0,0 +1,20 @@ +export const SECURITY_HEADERS_CONFIG = { + common: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=(), usb=()', + 'X-DNS-Prefetch-Control': 'off', + }, + hsts: { + production: 'max-age=31536000; includeSubDomains; preload', + development: null, // disabled in dev + }, + cacheControl: { + dynamic: 'no-cache, no-store, must-revalidate', + static: 'public, max-age=31536000', + private: 'private, no-cache', + }, + removeHeaders: ['X-Powered-By', 'Server', 'X-AspNet-Version', 'X-AspNetMvc-Version'], +}; diff --git a/backend/src/middleware/security/security-headers.middleware.ts b/backend/src/middleware/security/security-headers.middleware.ts new file mode 100644 index 0000000..f2f82a4 --- /dev/null +++ b/backend/src/middleware/security/security-headers.middleware.ts @@ -0,0 +1,39 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { SECURITY_HEADERS_CONFIG } from './security-headers.config'; + +@Injectable() +export class SecurityHeadersMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + // Apply common security headers + for (const [header, value] of Object.entries(SECURITY_HEADERS_CONFIG.common)) { + res.setHeader(header, value); + } + + // Apply HSTS only in production + if (process.env.NODE_ENV === 'production' && SECURITY_HEADERS_CONFIG.hsts.production) { + res.setHeader('Strict-Transport-Security', SECURITY_HEADERS_CONFIG.hsts.production); + } + + // Remove sensitive headers + SECURITY_HEADERS_CONFIG.removeHeaders.forEach((header) => { + res.removeHeader(header); + }); + + // Cache control based on content type + res.on('finish', () => { + const contentType = res.getHeader('Content-Type') as string; + if (!contentType) return; + + if (contentType.includes('application/json')) { + res.setHeader('Cache-Control', SECURITY_HEADERS_CONFIG.cacheControl.dynamic); + } else if (contentType.startsWith('text/') || contentType.includes('javascript') || contentType.includes('css')) { + res.setHeader('Cache-Control', SECURITY_HEADERS_CONFIG.cacheControl.static); + } else { + res.setHeader('Cache-Control', SECURITY_HEADERS_CONFIG.cacheControl.private); + } + }); + + next(); + } +} diff --git a/backend/src/middleware/transaction/transaction.logger.ts b/backend/src/middleware/transaction/transaction.logger.ts new file mode 100644 index 0000000..2e92309 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.logger.ts @@ -0,0 +1,14 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class TransactionLogger { + private readonly logger = new Logger('Transaction'); + + log(message: string) { + this.logger.log(message); + } + + error(message: string, error: any) { + this.logger.error(`${message}: ${error.message}`, error.stack); + } +} diff --git a/backend/src/middleware/transaction/transaction.manager.ts b/backend/src/middleware/transaction/transaction.manager.ts new file mode 100644 index 0000000..8175ec7 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.manager.ts @@ -0,0 +1,36 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +export class TransactionManager { + private queryRunner: QueryRunner; + + constructor(private readonly dataSource: DataSource) { + this.queryRunner = this.dataSource.createQueryRunner(); + } + + async startTransaction(isolation: 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE' = 'READ COMMITTED') { + await this.queryRunner.connect(); + await this.queryRunner.startTransaction(isolation); + } + + async commitTransaction() { + await this.queryRunner.commitTransaction(); + await this.queryRunner.release(); + } + + async rollbackTransaction() { + await this.queryRunner.rollbackTransaction(); + await this.queryRunner.release(); + } + + async createSavepoint(name: string) { + await this.queryRunner.query(`SAVEPOINT ${name}`); + } + + async rollbackToSavepoint(name: string) { + await this.queryRunner.query(`ROLLBACK TO SAVEPOINT ${name}`); + } + + getManager() { + return this.queryRunner.manager; + } +} diff --git a/backend/src/middleware/transaction/transaction.middleware.ts b/backend/src/middleware/transaction/transaction.middleware.ts new file mode 100644 index 0000000..11bb680 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.middleware.ts @@ -0,0 +1,37 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TransactionManager } from './transaction.manager'; +import { TransactionLogger } from './transaction.logger'; + +@Injectable() +export class TransactionMiddleware implements NestMiddleware { + constructor(private readonly dataSource: DataSource, private readonly logger: TransactionLogger) {} + + async use(req: Request, res: Response, next: NextFunction) { + const manager = new TransactionManager(this.dataSource); + + try { + await manager.startTransaction(); + + // Attach transaction manager to request for manual control if needed + (req as any).transactionManager = manager; + + res.on('finish', async () => { + if (res.statusCode >= 200 && res.statusCode < 400) { + await manager.commitTransaction(); + this.logger.log('Transaction committed successfully'); + } else { + await manager.rollbackTransaction(); + this.logger.log('Transaction rolled back due to error'); + } + }); + + next(); + } catch (error) { + await manager.rollbackTransaction(); + this.logger.error('Transaction failed', error); + next(error); + } + } +} diff --git a/package-lock.json b/package-lock.json index 6f7fb80..5beb79b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2", @@ -8005,6 +8006,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -14287,6 +14342,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index e8233f0..94643ec 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2",