diff --git a/.env.example b/.env.example index c861a1d..750779d 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,11 @@ PUSHER_APP_ID=your_app_id PUSHER_KEY=your_key PUSHER_SECRET=your_secret PUSHER_CLUSTER=your_cluster + +# --- Security Configuration --- +ALLOWED_ORIGINS=http://localhost:3000,https://yourapp.com +RATE_LIMIT_POINTS=100 +RATE_LIMIT_DURATION=60 +BODY_LIMIT=1mb +TRUST_PROXY=true +CSP_DEFAULT_SRC='self' diff --git a/README.md b/README.md index 4980cee..aacee3c 100644 --- a/README.md +++ b/README.md @@ -547,3 +547,36 @@ For technical support or questions: - [TypeORM Documentation](https://typeorm.io/) - [Stellar SDK](https://stellar.github.io/js-stellar-sdk/) - [OpenAPI Specification](https://swagger.io/specification/) + +## 🔒 Security + +StarShop Backend enforces robust security policies by default. These can be customized via environment variables: + +- **HTTP Security Headers**: All responses include standard headers (HSTS, X-Frame-Options, XSS Protection, NoSniff, CSP) via Helmet. +- **CORS Policy**: Origins are restricted using an allowlist from `ALLOWED_ORIGINS`. +- **Rate Limiting**: Requests are throttled globally using `@nestjs/throttler`. Configure limits with `RATE_LIMIT_POINTS` and `RATE_LIMIT_DURATION`. +- **Validation**: All payloads are validated and sanitized globally. Unknown fields are stripped and invalid payloads are rejected. +- **Body Size Limits**: Request payload size is limited via `BODY_LIMIT` (default: 1mb). Oversized requests return HTTP 413. +- **Trust Proxy**: Enable with `TRUST_PROXY=true` if running behind a load balancer or reverse proxy. +- **Content Security Policy (CSP)**: Defaults to `'self'`, configurable via `CSP_DEFAULT_SRC`. + +### Example Security Environment Variables + +```env +ALLOWED_ORIGINS=http://localhost:3000,https://yourapp.com +RATE_LIMIT_POINTS=100 +RATE_LIMIT_DURATION=60 +BODY_LIMIT=1mb +TRUST_PROXY=true +CSP_DEFAULT_SRC='self' +``` + +### Acceptance Criteria +- All responses include Helmet headers (HSTS in production) +- CORS allowlist enforced from env; requests from disallowed origins are rejected +- Global validation strips unknown fields and rejects invalid payloads +- Rate limiting returns 429 when limits are exceeded; limits configurable by env +- Body size limits enforced; oversized payloads return 413 +- CI passes and app boots with `npm run start:dev` and in production mode with security enabled + +See `src/security/` for implementation details and further customization. diff --git a/package-lock.json b/package-lock.json index cadfaa4..fed09c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/platform-express": "^11.1.0", "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.2.0", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", "@supabase/supabase-js": "^2.46.1", "@trustless-work/escrow": "^2.0.2", @@ -33,6 +34,7 @@ "express-async-handler": "^1.2.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.0", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", "multer-s3": "^3.0.1", @@ -82,7 +84,8 @@ "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "zod": "^3.22.4" } }, "node_modules/@ampproject/remapping": { @@ -3682,6 +3685,16 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.4.0.tgz", + "integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", @@ -9214,6 +9227,14 @@ "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==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-comment-regex": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", @@ -19163,6 +19184,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 6d4bafd..689b24c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@nestjs/platform-express": "^11.1.0", "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.2.0", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", "@supabase/supabase-js": "^2.46.1", "@trustless-work/escrow": "^2.0.2", @@ -62,6 +63,7 @@ "express-async-handler": "^1.2.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.0", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", "multer-s3": "^3.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 0e33b0d..64d83d4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,8 @@ import { SupabaseModule } from './modules/supabase/supabase.module'; import { EscrowModule } from './modules/escrow/escrow.module'; import { AppCacheModule } from './cache/cache.module'; import { StoresModule } from './modules/stores/stores.module'; +import { SecurityModule } from './security/security.module'; +import { ThrottlerGuard } from '@nestjs/throttler'; // Entities import { User } from './modules/users/entities/user.entity'; @@ -103,9 +105,16 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; OffersModule, EscrowModule, SupabaseModule, - EscrowModule, + EscrowModule, StoresModule, EscrowsModule, + SecurityModule, + ], + providers: [ + { + provide: 'APP_GUARD', + useClass: ThrottlerGuard, + }, ], }) export class AppModule { } diff --git a/src/main.ts b/src/main.ts index ba980fd..2a43ec1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,14 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { BuyerRequestSchedulerService } from './modules/buyer-requests/services/buyer-request-scheduler.service'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ConfigService } from '@nestjs/config'; +import helmet from 'helmet'; +import * as bodyParser from 'body-parser'; +import { ThrottlerGuard } from '@nestjs/throttler'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); // Middleware to track request start time for latency app.use((req, res, next) => { @@ -16,14 +21,57 @@ async function bootstrap(): Promise { next(); }); - // Enable CORS - app.enableCors(); + // Enable CORS with allowlist from config + const allowedOrigins = configService.get('security.allowedOrigins', ['http://localhost:3000']); + app.enableCors({ + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + }); // Global validation pipe app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }) ); + // Set body size limits + app.use(bodyParser.json({ limit: configService.get('security.bodyLimit', '1mb') })); + app.use(bodyParser.urlencoded({ extended: true, limit: configService.get('security.bodyLimit', '1mb') })); + + // Trust proxy if enabled + if (configService.get('security.trustProxy', false)) { + const expressApp = app.getHttpAdapter().getInstance(); + if (expressApp && typeof expressApp.set === 'function') { + expressApp.set('trust proxy', 1); + } + } + + // Helmet security headers + app.use( + helmet({ + contentSecurityPolicy: { + useDefaults: true, + directives: { + defaultSrc: [configService.get('security.cspDefaultSrc', "'self'")], + }, + }, + crossOriginEmbedderPolicy: true, + crossOriginResourcePolicy: { policy: 'same-origin' }, + frameguard: { action: 'deny' }, + hsts: process.env.NODE_ENV === 'production' ? undefined : false, + noSniff: true, + xssFilter: true, + }) + ); + + // Global rate limiting guard + app.useGlobalGuards(new ThrottlerGuard()); + // Global response interceptor app.useGlobalInterceptors(new ResponseInterceptor()); diff --git a/src/security/security.config.ts b/src/security/security.config.ts new file mode 100644 index 0000000..a436403 --- /dev/null +++ b/src/security/security.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('security', () => ({ + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], + rateLimitPoints: parseInt(process.env.RATE_LIMIT_POINTS || '100', 10), + rateLimitDuration: parseInt(process.env.RATE_LIMIT_DURATION || '60', 10), + trustProxy: process.env.TRUST_PROXY === 'true', + bodyLimit: process.env.BODY_LIMIT || '1mb', + cspDefaultSrc: process.env.CSP_DEFAULT_SRC || "'self'", +})); diff --git a/src/security/security.module.ts b/src/security/security.module.ts new file mode 100644 index 0000000..a153deb --- /dev/null +++ b/src/security/security.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import securityConfig from './security.config'; +import { ThrottlerModule } from '@nestjs/throttler'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [securityConfig], + }), + ThrottlerModule.forRootAsync({ + useFactory: (config) => ({ + ttl: config.get('security.rateLimitDuration'), + limit: config.get('security.rateLimitPoints'), + }), + inject: [ConfigModule], + }), + ], +}) +export class SecurityModule {}