From 056e7071e7799083a9a606ec4691d3c725ab3610 Mon Sep 17 00:00:00 2001 From: leandro-codee Date: Thu, 6 Aug 2026 12:01:56 -0400 Subject: [PATCH] feat(security): add rate limiting and transport hardening Harden the API before it handles real traffic. Every protection is applied in one place (configure-security.ts) that both main.ts and the e2e suite use, so the tests exercise the real configuration. Rate limiting - Three stacked windows (1s/10s/60s) enforced on every route via a global guard. One window cannot be both tight enough to stop a burst and loose enough to allow normal sustained traffic. - ProxyAwareThrottlerGuard normalizes the tracker (IPv6-mapped IPv4 is collapsed so one client cannot occupy two buckets), emits a canonical Retry-After (the stock guard only emits the suffixed variants when throttlers are named), and logs rejections. - TRUST_PROXY_HOPS defaults to 0, so X-Forwarded-For is ignored unless a proxy is declared. Getting this wrong allows rate-limit bypass, so both behaviours are covered by e2e tests. Transport security - Helmet with an API-appropriate CSP (every fetch directive denied), nosniff, frame-ancestors none, no-referrer and HSTS. - CORS disabled unless CORS_ORIGINS declares an exact allowlist; a wildcard combined with credentials throws at boot. - Body parsers capped at BODY_LIMIT (default 100kb). - Global ValidationPipe strips and then rejects unknown properties. - AllExceptionsFilter returns a fixed envelope and never leaks stack traces or driver messages. Client-error statuses raised by Express middleware are preserved but their messages are replaced. - Validated X-Request-Id on every request; client values are reused only when they match a UUID shape. Configuration - All settings are environment variables validated at startup, so an invalid security knob aborts the boot instead of degrading silently. A blank value is treated as unset. Tests: 124 unit + 22 e2e. Coverage 100% statements / 93% branches over non-wiring code, with an 85% threshold now enforced in CI. Known limitation: the throttler uses in-memory storage, so limits are per replica. docs/security.md documents the shared-storage migration path. --- .env.example | 46 +++ README.md | 29 +- docs/security.md | 179 ++++++++++ package.json | 20 +- pnpm-lock.yaml | 208 ++++++++++-- src/app.module.ts | 33 +- .../filters/all-exceptions.filter.spec.ts | 231 +++++++++++++ src/common/filters/all-exceptions.filter.ts | 131 ++++++++ .../middleware/request-id.middleware.spec.ts | 90 +++++ .../middleware/request-id.middleware.ts | 32 ++ .../security/configure-security.spec.ts | 178 ++++++++++ src/common/security/configure-security.ts | 69 ++++ src/common/security/cors.options.spec.ts | 99 ++++++ src/common/security/cors.options.ts | 73 ++++ src/common/security/helmet.options.spec.ts | 62 ++++ src/common/security/helmet.options.ts | 51 +++ .../proxy-aware-throttler.guard.spec.ts | 202 ++++++++++++ .../throttler/proxy-aware-throttler.guard.ts | 83 +++++ src/common/throttler/throttler.config.spec.ts | 106 ++++++ src/common/throttler/throttler.config.ts | 51 +++ src/config/app-config.module.ts | 34 ++ src/config/env.validation.spec.ts | 141 ++++++++ src/config/env.validation.ts | 198 +++++++++++ src/main.ts | 23 +- test/app.e2e-spec.ts | 17 +- test/security.e2e-spec.ts | 311 ++++++++++++++++++ test/utils/create-test-app.ts | 35 ++ 27 files changed, 2687 insertions(+), 45 deletions(-) create mode 100644 .env.example create mode 100644 docs/security.md create mode 100644 src/common/filters/all-exceptions.filter.spec.ts create mode 100644 src/common/filters/all-exceptions.filter.ts create mode 100644 src/common/middleware/request-id.middleware.spec.ts create mode 100644 src/common/middleware/request-id.middleware.ts create mode 100644 src/common/security/configure-security.spec.ts create mode 100644 src/common/security/configure-security.ts create mode 100644 src/common/security/cors.options.spec.ts create mode 100644 src/common/security/cors.options.ts create mode 100644 src/common/security/helmet.options.spec.ts create mode 100644 src/common/security/helmet.options.ts create mode 100644 src/common/throttler/proxy-aware-throttler.guard.spec.ts create mode 100644 src/common/throttler/proxy-aware-throttler.guard.ts create mode 100644 src/common/throttler/throttler.config.spec.ts create mode 100644 src/common/throttler/throttler.config.ts create mode 100644 src/config/app-config.module.ts create mode 100644 src/config/env.validation.spec.ts create mode 100644 src/config/env.validation.ts create mode 100644 test/security.e2e-spec.ts create mode 100644 test/utils/create-test-app.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a2a1e5b --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# Copy to .env for local development. Every value below has a safe default in +# src/config/env.validation.ts — the file only needs the keys you override. +# +# No secrets belong in this file or in .env: secrets are provisioned through +# Infisical and injected as environment variables at deploy time. + +# --- Runtime ----------------------------------------------------------------- +NODE_ENV=development +PORT=3000 + +# --- Reverse proxy ----------------------------------------------------------- +# Number of proxies in front of the app. 0 = ignore X-Forwarded-For entirely. +# Set this to the real hop count (1 behind a single ingress/load balancer). +# Too high and a client can forge its IP to bypass rate limiting. +TRUST_PROXY_HOPS=0 + +# --- CORS -------------------------------------------------------------------- +# Comma-separated allowlist. Empty = CORS disabled (no cross-origin access). +CORS_ORIGINS= +CORS_CREDENTIALS=false +CORS_MAX_AGE_SECONDS=600 + +# --- Rate limiting ----------------------------------------------------------- +# Three stacked windows; a request must satisfy all of them. +THROTTLE_SHORT_TTL_MS=1000 +THROTTLE_SHORT_LIMIT=10 + +THROTTLE_MEDIUM_TTL_MS=10000 +THROTTLE_MEDIUM_LIMIT=50 + +THROTTLE_LONG_TTL_MS=60000 +THROTTLE_LONG_LIMIT=200 + +# How long a client stays blocked after tripping a limit. +# 0 = blocked only until the window rolls over. +THROTTLE_BLOCK_DURATION_MS=0 + +# --- Payloads ---------------------------------------------------------------- +BODY_LIMIT=100kb + +# --- Security headers -------------------------------------------------------- +# Disable HSTS when TLS terminates at a proxy that already sets the header. +HSTS_ENABLED=true +HSTS_MAX_AGE_SECONDS=31536000 + +COMPRESSION_ENABLED=true diff --git a/README.md b/README.md index d30c946..315cebd 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,33 @@ $ pnpm run start:dev $ pnpm run start:prod ``` +## Configuration + +Copy `.env.example` to `.env` and override what you need. Every variable is +validated at startup, so an invalid value aborts the boot instead of silently +weakening a security control. + +## Security and rate limiting + +The API ships hardened by default: + +- **Rate limiting** — three stacked windows (1s/10s/60s) enforced on every route + via a global guard, with `X-RateLimit-*` and `Retry-After` headers. +- **Security headers** — Helmet with an API-appropriate CSP, `nosniff`, + `frame-ancestors 'none'`, `no-referrer` and HSTS. +- **CORS** — disabled unless `CORS_ORIGINS` declares an exact allowlist. +- **Payload limits** — body parsers capped at `BODY_LIMIT` (default `100kb`). +- **Input validation** — global `ValidationPipe` that strips and then rejects + unknown properties. +- **Error handling** — a fixed error envelope that never leaks stack traces, + driver messages or file paths. +- **Request correlation** — a validated `X-Request-Id` on every request. + +Read [`docs/security.md`](docs/security.md) before deploying. Two settings need +a deliberate decision per environment: `TRUST_PROXY_HOPS` (getting it wrong +allows rate-limit bypass) and the single-instance throttler storage (limits are +per replica). + ## Run tests ```bash @@ -53,7 +80,7 @@ $ pnpm run test # e2e tests $ pnpm run test:e2e -# test coverage +# test coverage (enforces an 85% threshold) $ pnpm run test:cov ``` diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..c3ee7a1 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,179 @@ +# Security and Rate Limiting + +Every protection described here is applied centrally in +[`src/common/security/configure-security.ts`](../src/common/security/configure-security.ts) +and in [`AppModule`](../src/app.module.ts). `main.ts` and the e2e suite share the +same entry point, so tests exercise the real configuration rather than an +approximation of it. + +## Configuration + +All knobs are environment variables validated at startup by +[`env.validation.ts`](../src/config/env.validation.ts). An invalid value aborts +the boot — a misconfigured security control never degrades silently. See +[`.env.example`](../.env.example) for the full list and defaults. + +A variable set to an empty value is treated as unset, so the declared default +applies. + +## Rate limiting + +Three named windows are stacked, and a request must satisfy all of them: + +| Window | Default TTL | Default limit | Purpose | +| -------- | ----------- | ------------- | ----------------------------- | +| `short` | 1 s | 10 | Absorbs bursts | +| `medium` | 10 s | 50 | Sustained-use guard | +| `long` | 60 s | 200 | Overall per-client budget | + +A single window cannot be both tight enough to stop a burst and loose enough to +allow normal sustained traffic; layering covers each case without punishing +legitimate clients. + +`ProxyAwareThrottlerGuard` is registered as an `APP_GUARD`, so it applies to +every route by default. + +### Per-route overrides + +```ts +import { SkipThrottle, Throttle } from '@nestjs/throttler'; + +@SkipThrottle() // exempt (e.g. health checks) +@Get('health') +health() {} + +@Throttle({ short: { limit: 3, ttl: 60_000 } }) // stricter than the default +@Post('login') +login() {} +``` + +### Response headers + +Successful responses carry the remaining budget per window +(`X-RateLimit-Limit-short`, `X-RateLimit-Remaining-short`, +`X-RateLimit-Reset-short`, and the `-medium` / `-long` equivalents) so clients +can back off before being rejected. + +A rejected request returns `429` with a canonical `Retry-After` header in +seconds. The stock guard only emits the suffixed `Retry-After-` variants +when throttlers are named, so `ProxyAwareThrottlerGuard` adds the standard one. + +### Client identity and `TRUST_PROXY_HOPS` + +Clients are tracked by `req.ip`, which Express derives from `X-Forwarded-For` +only when `trust proxy` is set. `TRUST_PROXY_HOPS` defaults to `0`, meaning the +header is ignored and the socket address is used. + +**Set this to the real number of proxies in front of the service.** If it is too +high, a client can prepend a forged `X-Forwarded-For` entry, get a fresh bucket +on every request and bypass rate limiting entirely. Both behaviours are covered +by e2e tests in [`test/security.e2e-spec.ts`](../test/security.e2e-spec.ts). + +IPv6-mapped IPv4 addresses (`::ffff:1.2.3.4`) are collapsed onto their IPv4 +form so one client cannot occupy two buckets. + +### Known limitation: single-instance storage + +The throttler uses the in-memory storage that ships with `@nestjs/throttler`. +Counters are per process, so **running N replicas multiplies the effective limit +by N**. + +Before scaling horizontally, move to shared storage: + +1. Add a storage adapter (for example `@nest-lab/throttler-storage-redis`). +2. Pass it as `storage` in `buildThrottlerOptions` + ([`throttler.config.ts`](../src/common/throttler/throttler.config.ts)) — the + options already use the object form, so this is a one-line change. +3. Provision the Redis connection through Infisical, not through committed + config. + +## Security headers + +Helmet is configured in +[`helmet.options.ts`](../src/common/security/helmet.options.ts) for a JSON API: + +| Header | Value | +| --------------------------- | ---------------------------------------------- | +| `Content-Security-Policy` | every fetch directive set to `'none'` | +| `X-Frame-Options` | `DENY` | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `no-referrer` | +| `Strict-Transport-Security` | 1 year, `includeSubDomains`, `preload` | +| `Cross-Origin-Opener-Policy`| `same-origin` | +| `X-Powered-By` | removed | + +The response body is never rendered as a document, so denying every CSP fetch +directive costs nothing and neutralises content-sniffing and reflected-payload +tricks against endpoints that echo user input. + +`Referrer-Policy: no-referrer` keeps URLs — and therefore any token in a query +string — from leaking to third parties. + +Disable HSTS (`HSTS_ENABLED=false`) only when TLS terminates at a proxy that +already sets the header. + +## CORS + +Disabled by default: with `CORS_ORIGINS` empty, no cross-origin preflight is +answered at all. An API with no declared browser clients should not advertise +one. + +When origins are configured, the allowlist is exact — no wildcard subdomain +matching. `CORS_ORIGINS=*` combined with `CORS_CREDENTIALS=true` throws at boot; +browsers reject that pairing anyway, and failing early beats debugging silently +dropped credentialed requests in production. + +## Input handling + +- **Body size** — both parsers are capped at `BODY_LIMIT` (default `100kb`). An + unbounded parser is a trivial memory-exhaustion vector. Oversized payloads get + `413`. +- **Validation** — the global `ValidationPipe` runs with `whitelist` and + `forbidNonWhitelisted`, so unknown properties are stripped and then rejected. + Mass-assignment and unexpected-field attacks stop at the boundary. DTOs need + `class-validator` decorators for this to have any effect. + +## Error responses + +[`AllExceptionsFilter`](../src/common/filters/all-exceptions.filter.ts) is the +terminal handler. Responses use a fixed envelope: + +```json +{ + "statusCode": 429, + "error": "ThrottlerException", + "message": "Too many requests. Please retry after the period indicated by the Retry-After header.", + "path": "/orders", + "timestamp": "2026-08-06T12:00:00.000Z", + "requestId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301" +} +``` + +Anything that is not an `HttpException` becomes a bare `500`. Stack traces, ORM +errors and driver messages are logged server-side but never reach the client, +since they routinely disclose schema details, file paths and connection strings. + +Express middleware (body parsers, CORS) rejects requests by throwing plain +errors that carry an HTTP status rather than an `HttpException`. Client-error +statuses from those are preserved, but the library's own message is replaced +with the standard reason phrase. A `5xx` from a library is still a bug on our +side, so it is reported and logged as a plain `500`. + +## Request correlation + +Every request carries an `X-Request-Id`, echoed on the response and included in +error bodies and logs. A client-supplied value is reused only when it matches a +UUID shape — an unvalidated header would be echoed into responses and log lines, +which is a log-injection and header-smuggling path. + +## Operational notes + +- `app.enableShutdownHooks()` is on, so in-flight requests drain on `SIGTERM`. +- Rate-limit rejections are logged at `warn` with the tracker, method, path and + hit count, which is what abuse detection needs. + +## Not covered here + +This layer is transport hardening. It does **not** provide authentication or +authorisation — those come from the `auth-middleware-ts` library and Auth0, and +must be added before the service handles real data. diff --git a/package.json b/package.json index 80cccdc..90d0383 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,20 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "coverage": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.5.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "compression": "^1.8.1", + "helmet": "^8.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -32,6 +39,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -63,9 +71,19 @@ "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "**/*.(t|j)s", + "!main.ts", + "!**/*.module.ts" ], "coverageDirectory": "../coverage", + "coverageThreshold": { + "global": { + "statements": 85, + "branches": 85, + "functions": 85, + "lines": 85 + } + }, "testEnvironment": "node" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48da28c..a0cb93a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,31 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.1 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.15.1 + version: 0.15.1 + compression: + specifier: ^1.8.1 + version: 1.8.1 + helmet: + specifier: ^8.3.0 + version: 8.3.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -38,7 +56,10 @@ importers: version: 11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -666,6 +687,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.28': resolution: {integrity: sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==} engines: {node: '>= 20'} @@ -712,6 +739,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + 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 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -773,6 +807,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -839,6 +876,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -946,61 +986,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1326,6 +1356,12 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1382,6 +1418,14 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1435,6 +1479,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1481,6 +1533,18 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1834,6 +1898,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2133,6 +2201,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.10: + resolution: {integrity: sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2258,6 +2329,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2277,6 +2351,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2317,6 +2395,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2906,6 +2988,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3739,7 +3825,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -3748,12 +3834,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.4.2 @@ -3762,12 +3859,12 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) - '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': + '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.2.0 @@ -3789,13 +3886,19 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': + '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 '@noble/hashes@1.8.0': {} @@ -3866,6 +3969,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.20.1 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 22.20.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.20.1 @@ -3949,6 +4057,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.11 + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4455,6 +4565,14 @@ snapshots: cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} + + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.10 + validator: 13.15.35 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4502,6 +4620,22 @@ snapshots: component-emitter@1.3.1: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + 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 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -4547,6 +4681,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4574,6 +4712,14 @@ snapshots: diff@4.0.4: {} + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4980,6 +5126,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.3.0: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -5446,6 +5594,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.10: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5541,6 +5691,8 @@ snapshots: minipass@7.1.3: {} + ms@2.0.0: {} + ms@2.1.3: {} multer@2.2.0: @@ -5556,6 +5708,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -5584,6 +5738,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6161,6 +6317,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.35: {} + vary@1.1.2: {} walker@1.0.8: diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..a905ac7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,35 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; +import { ProxyAwareThrottlerGuard } from './common/throttler/proxy-aware-throttler.guard'; +import { buildThrottlerOptions } from './common/throttler/throttler.config'; +import { AppConfigModule } from './config/app-config.module'; +import { EnvironmentVariables } from './config/env.validation'; @Module({ - imports: [], + imports: [ + AppConfigModule, + ThrottlerModule.forRootAsync({ + inject: [EnvironmentVariables], + useFactory: buildThrottlerOptions, + }), + ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + // Applied to every route by default; opt out per handler with + // `@SkipThrottle()` and tighten specific ones with `@Throttle()`. + provide: APP_GUARD, + useClass: ProxyAwareThrottlerGuard, + }, + ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestIdMiddleware).forRoutes('*'); + } +} diff --git a/src/common/filters/all-exceptions.filter.spec.ts b/src/common/filters/all-exceptions.filter.spec.ts new file mode 100644 index 0000000..f5dc628 --- /dev/null +++ b/src/common/filters/all-exceptions.filter.spec.ts @@ -0,0 +1,231 @@ +import { + ArgumentsHost, + BadRequestException, + HttpException, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware'; +import { + AllExceptionsFilter, + ErrorResponseBody, +} from './all-exceptions.filter'; + +const REQUEST_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + +interface Harness { + filter: AllExceptionsFilter; + host: ArgumentsHost; + reply: jest.Mock; + body: () => ErrorResponseBody; + status: () => number; +} + +const createHarness = ( + headers: Record = { [REQUEST_ID_HEADER]: REQUEST_ID }, +): Harness => { + const reply = jest.fn(); + const adapterHost = { + httpAdapter: { reply, getRequestUrl: () => '/orders' }, + } as unknown as HttpAdapterHost; + + const host = { + switchToHttp: () => ({ + getRequest: () => ({ headers, url: '/orders' }), + getResponse: () => ({}), + }), + } as unknown as ArgumentsHost; + + const filter = new AllExceptionsFilter(adapterHost); + jest.spyOn(filter['logger'], 'error').mockImplementation(() => undefined); + + return { + filter, + host, + reply, + body: () => (reply.mock.calls[0] as unknown[])[1] as ErrorResponseBody, + status: () => (reply.mock.calls[0] as unknown[])[2] as number, + }; +}; + +describe('AllExceptionsFilter', () => { + describe('HttpException', () => { + it('preserves the original status code', () => { + const harness = createHarness(); + + harness.filter.catch( + new NotFoundException('Order not found'), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.NOT_FOUND); + expect(harness.body().message).toBe('Order not found'); + }); + + it('keeps validation error arrays intact', () => { + const harness = createHarness(); + + harness.filter.catch( + new BadRequestException(['name must be a string']), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.BAD_REQUEST); + expect(harness.body().message).toEqual(['name must be a string']); + }); + + it('handles a string exception body', () => { + const harness = createHarness(); + + harness.filter.catch( + new HttpException('Teapot', HttpStatus.I_AM_A_TEAPOT), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.I_AM_A_TEAPOT); + expect(harness.body().message).toBe('Teapot'); + }); + }); + + describe('unknown exceptions', () => { + it('reports a generic 500 instead of the real error', () => { + const harness = createHarness(); + + harness.filter.catch( + new Error('connection to postgres://user:pw@db failed'), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(harness.body().message).toBe('Internal server error'); + }); + + it('never leaks a stack trace to the client', () => { + const harness = createHarness(); + + harness.filter.catch(new Error('boom'), harness.host); + + expect(JSON.stringify(harness.body())).not.toContain('boom'); + expect(harness.body()).not.toHaveProperty('stack'); + }); + + it('handles a thrown non-Error value', () => { + const harness = createHarness(); + + harness.filter.catch('something odd', harness.host); + + expect(harness.status()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(harness.body().message).toBe('Internal server error'); + }); + + it('logs the full error server-side for diagnosis', () => { + const harness = createHarness(); + const error = jest.spyOn(harness.filter['logger'], 'error'); + + harness.filter.catch(new Error('boom'), harness.host); + + expect(error).toHaveBeenCalledTimes(1); + }); + }); + + describe('errors raised by Express middleware', () => { + it('preserves a client-error status from the body parser', () => { + const harness = createHarness(); + const payloadTooLarge = Object.assign( + new Error('request entity too large'), + { status: 413, type: 'entity.too.large', length: 4096 }, + ); + + harness.filter.catch(payloadTooLarge, harness.host); + + expect(harness.status()).toBe(HttpStatus.PAYLOAD_TOO_LARGE); + expect(harness.body().message).toBe('Payload Too Large'); + }); + + it('reads statusCode as well as status', () => { + const harness = createHarness(); + + harness.filter.catch( + Object.assign(new Error('bad json'), { statusCode: 400 }), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.BAD_REQUEST); + }); + + it('replaces the library message so internals stay hidden', () => { + const harness = createHarness(); + + harness.filter.catch( + Object.assign(new Error('entity too large: /var/app/uploads'), { + status: 413, + }), + harness.host, + ); + + expect(JSON.stringify(harness.body())).not.toContain('/var/app/uploads'); + }); + + it('reports a library 5xx as a plain 500', () => { + const harness = createHarness(); + + harness.filter.catch( + Object.assign(new Error('upstream exploded'), { status: 502 }), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(harness.body().message).toBe('Internal server error'); + }); + + it('ignores a non-numeric status', () => { + const harness = createHarness(); + + harness.filter.catch( + Object.assign(new Error('weird'), { status: 'nope' }), + harness.host, + ); + + expect(harness.status()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + + describe('response envelope', () => { + it('includes the correlation id', () => { + const harness = createHarness(); + + harness.filter.catch(new NotFoundException(), harness.host); + + expect(harness.body().requestId).toBe(REQUEST_ID); + }); + + it('omits the correlation id when there is none', () => { + const harness = createHarness({}); + + harness.filter.catch(new NotFoundException(), harness.host); + + expect(harness.body()).not.toHaveProperty('requestId'); + }); + + it('carries the path and an ISO timestamp', () => { + const harness = createHarness(); + + harness.filter.catch(new NotFoundException(), harness.host); + + expect(harness.body().path).toBe('/orders'); + expect(new Date(harness.body().timestamp).toISOString()).toBe( + harness.body().timestamp, + ); + }); + + it('does not log client errors as server failures', () => { + const harness = createHarness(); + const error = jest.spyOn(harness.filter['logger'], 'error'); + + harness.filter.catch(new NotFoundException(), harness.host); + + expect(error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..13a5f4d --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,131 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import type { Request } from 'express'; +import { STATUS_CODES } from 'node:http'; +import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware'; + +export interface ErrorResponseBody { + statusCode: number; + error: string; + message: string | string[]; + path: string; + timestamp: string; + requestId?: string; +} + +const GENERIC_MESSAGE = 'Internal server error'; + +const CLIENT_ERROR_MIN_STATUS = 400; +const CLIENT_ERROR_MAX_STATUS = 499; + +interface HttpExceptionBody { + message?: string | string[]; + error?: string; +} + +/** + * Express middleware (body parsers, CORS, compression) rejects requests by + * throwing plain errors that carry an HTTP status rather than `HttpException`. + * Their own messages can be implementation-revealing, so only the status is + * kept and the message is replaced by the standard reason phrase. + */ +function extractMiddlewareStatus(exception: unknown): number | null { + if (typeof exception !== 'object' || exception === null) { + return null; + } + + const candidate = exception as { status?: unknown; statusCode?: unknown }; + const status = candidate.status ?? candidate.statusCode; + + if (typeof status !== 'number' || !Number.isInteger(status)) { + return null; + } + + // Only client errors are trusted: a 5xx from a library is still a bug on our + // side and should be reported — and logged — as a plain 500. + const isClientError = + status >= CLIENT_ERROR_MIN_STATUS && status <= CLIENT_ERROR_MAX_STATUS; + + return isClientError ? status : null; +} + +function extractHttpExceptionBody(exception: HttpException): { + message: string | string[]; + error: string; +} { + const response: unknown = exception.getResponse(); + + if (typeof response === 'string') { + return { message: response, error: exception.name }; + } + + const body = response as HttpExceptionBody; + return { + message: body.message ?? exception.message, + error: body.error ?? exception.name, + }; +} + +/** + * Terminal error handler. Anything that is not an `HttpException` is reported + * as a bare 500: stack traces, ORM errors and driver messages are logged + * server-side but never reach the client, since they routinely disclose schema + * details, file paths and connection strings. + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + constructor(private readonly httpAdapterHost: HttpAdapterHost) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const requestId = request?.headers?.[REQUEST_ID_HEADER]; + + const middlewareStatus = extractMiddlewareStatus(exception); + const isHttpException = exception instanceof HttpException; + + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let message: string | string[] = GENERIC_MESSAGE; + let error = GENERIC_MESSAGE; + + if (isHttpException) { + statusCode = exception.getStatus(); + ({ message, error } = extractHttpExceptionBody(exception)); + } else if (middlewareStatus !== null) { + statusCode = middlewareStatus; + error = STATUS_CODES[middlewareStatus] ?? 'Request rejected'; + message = error; + } + + const requestUrl: unknown = httpAdapter.getRequestUrl(request); + const path = typeof requestUrl === 'string' ? requestUrl : ''; + + const body: ErrorResponseBody = { + statusCode, + error, + message, + path, + timestamp: new Date().toISOString(), + ...(typeof requestId === 'string' ? { requestId } : {}), + }; + + if (statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.error( + `Unhandled ${statusCode} on ${path} (requestId=${String(requestId)})`, + exception instanceof Error ? exception.stack : String(exception), + ); + } + + httpAdapter.reply(ctx.getResponse(), body, statusCode); + } +} diff --git a/src/common/middleware/request-id.middleware.spec.ts b/src/common/middleware/request-id.middleware.spec.ts new file mode 100644 index 0000000..c490c54 --- /dev/null +++ b/src/common/middleware/request-id.middleware.spec.ts @@ -0,0 +1,90 @@ +import type { NextFunction, Request, Response } from 'express'; +import { + REQUEST_ID_HEADER, + RequestIdMiddleware, + resolveRequestId, +} from './request-id.middleware'; + +const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe('resolveRequestId', () => { + it('reuses a well-formed incoming id', () => { + expect(resolveRequestId(UUID)).toBe(UUID); + }); + + it('lowercases the reused id for stable correlation', () => { + expect(resolveRequestId(UUID.toUpperCase())).toBe(UUID); + }); + + it('generates a fresh id when none is supplied', () => { + expect(resolveRequestId(undefined)).toMatch(UUID_PATTERN); + }); + + it.each([ + ['not-a-uuid', 'a free-form string'], + ['', 'an HTML payload'], + ['id\r\nX-Injected: 1', 'a header-injection attempt'], + ['', 'an empty string'], + ])('ignores %p (%s)', (incoming) => { + const resolved = resolveRequestId(incoming); + + expect(resolved).not.toBe(incoming); + expect(resolved).toMatch(UUID_PATTERN); + }); + + it('ignores a non-string value such as a repeated header', () => { + expect(resolveRequestId([UUID, UUID])).toMatch(UUID_PATTERN); + }); +}); + +describe('RequestIdMiddleware', () => { + const run = ( + headers: Record = {}, + ): { + req: Request; + res: Response; + next: NextFunction; + setHeader: jest.Mock; + } => { + const setHeader = jest.fn(); + const req = { headers } as unknown as Request; + const res = { setHeader } as unknown as Response; + const next = jest.fn() as unknown as NextFunction; + + new RequestIdMiddleware().use(req, res, next); + + return { req, res, next, setHeader }; + }; + + it('echoes the resolved id back on the response', () => { + const { setHeader } = run({ [REQUEST_ID_HEADER]: UUID }); + + expect(setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, UUID); + }); + + it('exposes the id on the request for downstream handlers', () => { + const { req } = run(); + + expect(req.headers[REQUEST_ID_HEADER]).toMatch(UUID_PATTERN); + }); + + it('overwrites a malformed client-supplied id', () => { + const { req, setHeader } = run({ + [REQUEST_ID_HEADER]: 'bogus\r\ninjected', + }); + + expect(req.headers[REQUEST_ID_HEADER]).toMatch(UUID_PATTERN); + expect(setHeader).not.toHaveBeenCalledWith( + REQUEST_ID_HEADER, + 'bogus\r\ninjected', + ); + }); + + it('continues the middleware chain', () => { + const { next } = run(); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/common/middleware/request-id.middleware.ts b/src/common/middleware/request-id.middleware.ts new file mode 100644 index 0000000..edb1690 --- /dev/null +++ b/src/common/middleware/request-id.middleware.ts @@ -0,0 +1,32 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +export const REQUEST_ID_HEADER = 'x-request-id'; + +/** UUID v4 / v7-shaped. Anything else is treated as absent. */ +const REQUEST_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function resolveRequestId(incoming: unknown): string { + return typeof incoming === 'string' && REQUEST_ID_PATTERN.test(incoming) + ? incoming.toLowerCase() + : randomUUID(); +} + +/** + * Correlation id for every request. Client-supplied values are only reused when + * they match a UUID shape — an unvalidated header would end up echoed into + * responses and log lines, which is a log-injection and header-smuggling path. + */ +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + const requestId = resolveRequestId(req.headers[REQUEST_ID_HEADER]); + + req.headers[REQUEST_ID_HEADER] = requestId; + res.setHeader(REQUEST_ID_HEADER, requestId); + + next(); + } +} diff --git a/src/common/security/configure-security.spec.ts b/src/common/security/configure-security.spec.ts new file mode 100644 index 0000000..6cf61a6 --- /dev/null +++ b/src/common/security/configure-security.spec.ts @@ -0,0 +1,178 @@ +import { ValidationPipe } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { EnvironmentVariables, validateEnv } from '../../config/env.validation'; +import { AllExceptionsFilter } from '../filters/all-exceptions.filter'; +import { configureSecurity } from './configure-security'; + +interface FakeApp { + app: NestExpressApplication; + disable: jest.Mock; + set: jest.Mock; + use: jest.Mock; + useBodyParser: jest.Mock; + enableCors: jest.Mock; + useGlobalPipes: jest.Mock; + useGlobalFilters: jest.Mock; + enableShutdownHooks: jest.Mock; +} + +const createFakeApp = (env: Record = {}): FakeApp => { + const config = validateEnv(env); + const mocks = { + disable: jest.fn(), + set: jest.fn(), + use: jest.fn(), + useBodyParser: jest.fn(), + enableCors: jest.fn(), + useGlobalPipes: jest.fn(), + useGlobalFilters: jest.fn(), + enableShutdownHooks: jest.fn(), + }; + + const app = { + ...mocks, + get: (token: unknown) => + token === EnvironmentVariables ? config : ({} as HttpAdapterHost), + } as unknown as NestExpressApplication; + + return { app, ...mocks }; +}; + +describe('configureSecurity', () => { + it('removes the x-powered-by fingerprint', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.disable).toHaveBeenCalledWith('x-powered-by'); + }); + + describe('trust proxy', () => { + it('trusts no proxy by default', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.set).toHaveBeenCalledWith('trust proxy', 0); + }); + + it('applies the configured hop count', () => { + const fake = createFakeApp({ TRUST_PROXY_HOPS: '2' }); + + configureSecurity(fake.app); + + expect(fake.set).toHaveBeenCalledWith('trust proxy', 2); + }); + }); + + describe('body limits', () => { + it('caps both parsers at the configured size', () => { + const fake = createFakeApp({ BODY_LIMIT: '256kb' }); + + configureSecurity(fake.app); + + expect(fake.useBodyParser).toHaveBeenCalledWith('json', { + limit: '256kb', + }); + expect(fake.useBodyParser).toHaveBeenCalledWith('urlencoded', { + limit: '256kb', + extended: true, + }); + }); + }); + + describe('middleware', () => { + it('installs helmet', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.use).toHaveBeenCalled(); + }); + + it('installs compression when enabled', () => { + const fake = createFakeApp({ COMPRESSION_ENABLED: 'true' }); + + configureSecurity(fake.app); + + expect(fake.use).toHaveBeenCalledTimes(2); + }); + + it('skips compression when disabled', () => { + const fake = createFakeApp({ COMPRESSION_ENABLED: 'false' }); + + configureSecurity(fake.app); + + expect(fake.use).toHaveBeenCalledTimes(1); + }); + }); + + describe('CORS', () => { + it('stays off when no origin is allowlisted', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.enableCors).not.toHaveBeenCalled(); + }); + + it('is enabled for an allowlisted origin', () => { + const fake = createFakeApp({ CORS_ORIGINS: 'https://app.example.com' }); + + configureSecurity(fake.app); + + expect(fake.enableCors).toHaveBeenCalledWith( + expect.objectContaining({ origin: ['https://app.example.com'] }), + ); + }); + }); + + describe('validation pipe', () => { + it('rejects unknown properties instead of ignoring them', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + const pipe = ( + fake.useGlobalPipes.mock.calls[0] as unknown[] + )[0] as ValidationPipe; + expect(pipe).toBeInstanceOf(ValidationPipe); + expect(pipe).toMatchObject({ + validatorOptions: expect.objectContaining({ + whitelist: true, + forbidNonWhitelisted: true, + }) as unknown, + }); + }); + }); + + it('installs the non-leaking exception filter', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.useGlobalFilters).toHaveBeenCalledWith( + expect.any(AllExceptionsFilter), + ); + }); + + it('enables graceful shutdown', () => { + const fake = createFakeApp(); + + configureSecurity(fake.app); + + expect(fake.enableShutdownHooks).toHaveBeenCalled(); + }); + + it('fails fast on a contradictory CORS policy', () => { + const fake = createFakeApp({ + CORS_ORIGINS: '*', + CORS_CREDENTIALS: 'true', + }); + + expect(() => configureSecurity(fake.app)).toThrow( + /cannot be combined with CORS_CREDENTIALS/, + ); + }); +}); diff --git a/src/common/security/configure-security.ts b/src/common/security/configure-security.ts new file mode 100644 index 0000000..abe5984 --- /dev/null +++ b/src/common/security/configure-security.ts @@ -0,0 +1,69 @@ +import { Logger, ValidationPipe } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import compression from 'compression'; +import helmet from 'helmet'; +import { EnvironmentVariables } from '../../config/env.validation'; +import { AllExceptionsFilter } from '../filters/all-exceptions.filter'; +import { buildCorsOptions } from './cors.options'; +import { buildHelmetOptions } from './helmet.options'; + +const logger = new Logger('Security'); + +/** + * Single place where every transport-level protection is applied, so `main.ts` + * and the e2e suite exercise the exact same hardening. Order matters: security + * headers and body limits must run before anything touches the payload. + */ +export function configureSecurity(app: NestExpressApplication): void { + const config = app.get(EnvironmentVariables); + + // Express advertises itself by default; the header only helps fingerprinting. + app.disable('x-powered-by'); + + // Controls whether `X-Forwarded-For` is believed. Over-counting hops lets a + // client forge its own IP and bypass the rate limiter, so this is explicit. + app.set('trust proxy', config.TRUST_PROXY_HOPS); + + app.use(helmet(buildHelmetOptions(config))); + + if (config.COMPRESSION_ENABLED) { + app.use(compression()); + } + + // Caps the payload before it is buffered — an unbounded parser is a trivial + // memory-exhaustion vector. + app.useBodyParser('json', { limit: config.BODY_LIMIT }); + app.useBodyParser('urlencoded', { + limit: config.BODY_LIMIT, + extended: true, + }); + + const corsOptions = buildCorsOptions(config); + if (corsOptions) { + app.enableCors(corsOptions); + } else { + logger.log('CORS disabled: no origin configured in CORS_ORIGINS'); + } + + app.useGlobalPipes( + new ValidationPipe({ + // Strips unknown properties, then rejects the request if any were sent: + // mass-assignment and unexpected-field attacks stop at the boundary. + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: false }, + forbidUnknownValues: true, + }), + ); + + app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost))); + + app.enableShutdownHooks(); + + logger.log( + `Security enabled (trustProxyHops=${config.TRUST_PROXY_HOPS}, ` + + `bodyLimit=${config.BODY_LIMIT}, cors=${corsOptions ? 'on' : 'off'})`, + ); +} diff --git a/src/common/security/cors.options.spec.ts b/src/common/security/cors.options.spec.ts new file mode 100644 index 0000000..1902bb0 --- /dev/null +++ b/src/common/security/cors.options.spec.ts @@ -0,0 +1,99 @@ +import { EnvironmentVariables, validateEnv } from '../../config/env.validation'; +import { buildCorsOptions, parseOrigins } from './cors.options'; + +const configFor = (env: Record = {}): EnvironmentVariables => + validateEnv(env); + +describe('parseOrigins', () => { + it('splits a comma-separated list', () => { + expect(parseOrigins('https://a.com,https://b.com')).toEqual([ + 'https://a.com', + 'https://b.com', + ]); + }); + + it('trims whitespace and drops empty entries', () => { + expect(parseOrigins(' https://a.com , , https://b.com ')).toEqual([ + 'https://a.com', + 'https://b.com', + ]); + }); + + it('returns an empty list for an empty string', () => { + expect(parseOrigins('')).toEqual([]); + }); +}); + +describe('buildCorsOptions', () => { + it('returns null when no origin is allowlisted', () => { + expect(buildCorsOptions(configFor())).toBeNull(); + }); + + it('allowlists the configured origins', () => { + const options = buildCorsOptions( + configFor({ CORS_ORIGINS: 'https://app.example.com' }), + ); + + expect(options?.origin).toEqual(['https://app.example.com']); + expect(options?.credentials).toBe(false); + }); + + it('enables credentials only when explicitly requested', () => { + const options = buildCorsOptions( + configFor({ + CORS_ORIGINS: 'https://app.example.com', + CORS_CREDENTIALS: 'true', + }), + ); + + expect(options?.credentials).toBe(true); + }); + + it('exposes the rate-limit headers so clients can self-throttle', () => { + const options = buildCorsOptions( + configFor({ CORS_ORIGINS: 'https://app.example.com' }), + ); + + expect(options?.exposedHeaders).toEqual( + expect.arrayContaining([ + 'Retry-After', + 'X-RateLimit-Limit', + 'X-RateLimit-Remaining', + ]), + ); + }); + + it('does not allow arbitrary request headers', () => { + const options = buildCorsOptions( + configFor({ CORS_ORIGINS: 'https://app.example.com' }), + ); + + expect(options?.allowedHeaders).not.toContain('*'); + }); + + it('allows a wildcard origin when credentials are off', () => { + const options = buildCorsOptions(configFor({ CORS_ORIGINS: '*' })); + + expect(options?.origin).toBe('*'); + expect(options?.credentials).toBe(false); + }); + + it('refuses a wildcard origin combined with credentials', () => { + expect(() => + buildCorsOptions( + configFor({ CORS_ORIGINS: '*', CORS_CREDENTIALS: 'true' }), + ), + ).toThrow(/cannot be combined with CORS_CREDENTIALS/); + }); + + it('honours the configured preflight cache duration', () => { + const options = buildCorsOptions( + configFor({ + CORS_ORIGINS: 'https://app.example.com', + CORS_MAX_AGE_SECONDS: '120', + }), + ); + + expect(options?.maxAge).toBe(120); + }); +}); diff --git a/src/common/security/cors.options.ts b/src/common/security/cors.options.ts new file mode 100644 index 0000000..b5757fd --- /dev/null +++ b/src/common/security/cors.options.ts @@ -0,0 +1,73 @@ +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import { EnvironmentVariables } from '../../config/env.validation'; + +const WILDCARD = '*'; + +const ALLOWED_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']; + +const ALLOWED_HEADERS = [ + 'Content-Type', + 'Authorization', + 'Accept', + 'X-Request-Id', +]; + +const EXPOSED_HEADERS = [ + 'X-Request-Id', + 'Retry-After', + 'X-RateLimit-Limit', + 'X-RateLimit-Remaining', + 'X-RateLimit-Reset', +]; + +export function parseOrigins(raw: string): string[] { + return raw + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +/** + * Builds the CORS policy from the allowlist. Returns `null` when no origin is + * configured so the caller can leave CORS off altogether — an API with no + * declared browser clients should not answer cross-origin preflights at all. + */ +export function buildCorsOptions( + config: EnvironmentVariables, +): CorsOptions | null { + const origins = parseOrigins(config.CORS_ORIGINS); + const credentials = config.CORS_CREDENTIALS; + + if (origins.length === 0) { + return null; + } + + if (origins.includes(WILDCARD)) { + if (credentials) { + // The browser rejects this pairing anyway; failing at boot beats + // debugging silently dropped credentialed requests in production. + throw new Error( + 'CORS_ORIGINS="*" cannot be combined with CORS_CREDENTIALS=true', + ); + } + return { + origin: WILDCARD, + methods: ALLOWED_METHODS, + allowedHeaders: ALLOWED_HEADERS, + exposedHeaders: EXPOSED_HEADERS, + credentials: false, + maxAge: config.CORS_MAX_AGE_SECONDS, + optionsSuccessStatus: 204, + }; + } + + return { + origin: origins, + methods: ALLOWED_METHODS, + allowedHeaders: ALLOWED_HEADERS, + exposedHeaders: EXPOSED_HEADERS, + credentials, + maxAge: config.CORS_MAX_AGE_SECONDS, + optionsSuccessStatus: 204, + }; +} diff --git a/src/common/security/helmet.options.spec.ts b/src/common/security/helmet.options.spec.ts new file mode 100644 index 0000000..5a7f532 --- /dev/null +++ b/src/common/security/helmet.options.spec.ts @@ -0,0 +1,62 @@ +import { EnvironmentVariables, validateEnv } from '../../config/env.validation'; +import { buildHelmetOptions } from './helmet.options'; + +const configFor = (env: Record = {}): EnvironmentVariables => + validateEnv(env); + +describe('buildHelmetOptions', () => { + it('denies every content-loading directive for a JSON API', () => { + const csp = buildHelmetOptions(configFor()).contentSecurityPolicy; + + expect(csp).toMatchObject({ + useDefaults: false, + directives: { + 'default-src': ["'none'"], + 'frame-ancestors': ["'none'"], + 'base-uri': ["'none'"], + 'form-action': ["'none'"], + }, + }); + }); + + it('blocks framing and MIME sniffing', () => { + const options = buildHelmetOptions(configFor()); + + expect(options.frameguard).toEqual({ action: 'deny' }); + expect(options.noSniff).toBe(true); + }); + + it('never leaks the URL through the Referer header', () => { + expect(buildHelmetOptions(configFor()).referrerPolicy).toEqual({ + policy: 'no-referrer', + }); + }); + + it('hides the framework fingerprint', () => { + expect(buildHelmetOptions(configFor()).hidePoweredBy).toBe(true); + }); + + describe('HSTS', () => { + it('is enabled with subdomains and preload by default', () => { + expect(buildHelmetOptions(configFor()).hsts).toEqual({ + maxAge: 31_536_000, + includeSubDomains: true, + preload: true, + }); + }); + + it('honours a custom max age', () => { + const options = buildHelmetOptions( + configFor({ HSTS_MAX_AGE_SECONDS: '600' }), + ); + + expect(options.hsts).toMatchObject({ maxAge: 600 }); + }); + + it('can be turned off for deployments behind a TLS-terminating proxy', () => { + expect( + buildHelmetOptions(configFor({ HSTS_ENABLED: 'false' })).hsts, + ).toBe(false); + }); + }); +}); diff --git a/src/common/security/helmet.options.ts b/src/common/security/helmet.options.ts new file mode 100644 index 0000000..6966404 --- /dev/null +++ b/src/common/security/helmet.options.ts @@ -0,0 +1,51 @@ +import { HelmetOptions } from 'helmet'; +import { EnvironmentVariables } from '../../config/env.validation'; + +/** + * CSP for a JSON API: the response body is never rendered as a document, so + * every fetch directive is denied. This neutralises content-sniffing and + * reflected-payload tricks against endpoints that echo user input. + */ +const API_CSP_DIRECTIVES = { + 'default-src': ["'none'"], + 'base-uri': ["'none'"], + 'form-action': ["'none'"], + 'frame-ancestors': ["'none'"], + 'script-src': ["'none'"], + 'style-src': ["'none'"], + 'img-src': ["'none'"], + 'connect-src': ["'none'"], + 'font-src': ["'none'"], + 'object-src': ["'none'"], +}; + +export function buildHelmetOptions( + config: EnvironmentVariables, +): HelmetOptions { + return { + contentSecurityPolicy: { + useDefaults: false, + directives: API_CSP_DIRECTIVES, + }, + // Keeps other origins from embedding or reading our responses. + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: { policy: 'same-origin' }, + crossOriginResourcePolicy: { policy: 'same-site' }, + // No URL, and therefore no token in a query string, leaks to third parties. + referrerPolicy: { policy: 'no-referrer' }, + // Off behind a proxy that terminates TLS itself, on by default otherwise. + hsts: config.HSTS_ENABLED + ? { + maxAge: config.HSTS_MAX_AGE_SECONDS, + includeSubDomains: true, + preload: true, + } + : false, + noSniff: true, + frameguard: { action: 'deny' }, + hidePoweredBy: true, + ieNoOpen: true, + dnsPrefetchControl: { allow: false }, + xssFilter: true, + }; +} diff --git a/src/common/throttler/proxy-aware-throttler.guard.spec.ts b/src/common/throttler/proxy-aware-throttler.guard.spec.ts new file mode 100644 index 0000000..5b488dd --- /dev/null +++ b/src/common/throttler/proxy-aware-throttler.guard.spec.ts @@ -0,0 +1,202 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + ThrottlerException, + ThrottlerLimitDetail, + ThrottlerStorage, +} from '@nestjs/throttler'; +import { + normalizeIp, + ProxyAwareThrottlerGuard, + UNKNOWN_TRACKER, +} from './proxy-aware-throttler.guard'; + +class TestableGuard extends ProxyAwareThrottlerGuard { + trackerFor(req: Record): Promise { + return this.getTracker(req); + } + + rejectWith( + context: ExecutionContext, + detail: ThrottlerLimitDetail, + ): Promise { + return this.throwThrottlingException(context, detail); + } +} + +const createGuard = (): TestableGuard => + new TestableGuard( + { throttlers: [] }, + {} as ThrottlerStorage, + new Reflector(), + ); + +const createContext = ( + res: { header: jest.Mock }, + req: Record = { method: 'GET', url: '/' }, +): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => res, + }), + }) as unknown as ExecutionContext; + +const detail = ( + overrides: Partial = {}, +): ThrottlerLimitDetail => ({ + limit: 10, + ttl: 1_000, + key: 'key', + tracker: '1.2.3.4', + totalHits: 11, + timeToExpire: 1, + isBlocked: true, + timeToBlockExpire: 5, + ...overrides, +}); + +describe('normalizeIp', () => { + it('collapses IPv6-mapped IPv4 addresses onto a single bucket', () => { + expect(normalizeIp('::ffff:203.0.113.7')).toBe('203.0.113.7'); + }); + + it('lowercases IPv6 addresses so casing cannot split a bucket', () => { + expect(normalizeIp('2001:DB8::1')).toBe('2001:db8::1'); + }); + + it('passes plain IPv4 through unchanged', () => { + expect(normalizeIp('203.0.113.7')).toBe('203.0.113.7'); + }); + + it('trims surrounding whitespace', () => { + expect(normalizeIp(' 203.0.113.7 ')).toBe('203.0.113.7'); + }); + + it.each([undefined, null, '', ' '])( + 'falls back to a shared bucket for %p', + (value) => { + expect(normalizeIp(value)).toBe(UNKNOWN_TRACKER); + }, + ); +}); + +describe('ProxyAwareThrottlerGuard', () => { + describe('getTracker', () => { + it('tracks by the resolved request IP', async () => { + await expect( + createGuard().trackerFor({ ip: '203.0.113.7' }), + ).resolves.toBe('203.0.113.7'); + }); + + it('normalizes the request IP', async () => { + await expect( + createGuard().trackerFor({ ip: '::ffff:203.0.113.7' }), + ).resolves.toBe('203.0.113.7'); + }); + + it('falls back to the socket address when req.ip is missing', async () => { + await expect( + createGuard().trackerFor({ socket: { remoteAddress: '198.51.100.9' } }), + ).resolves.toBe('198.51.100.9'); + }); + + it('never returns undefined', async () => { + await expect(createGuard().trackerFor({})).resolves.toBe(UNKNOWN_TRACKER); + }); + + it('ignores a non-string IP', async () => { + await expect(createGuard().trackerFor({ ip: 42 })).resolves.toBe( + UNKNOWN_TRACKER, + ); + }); + }); + + describe('throwThrottlingException', () => { + it('sets a canonical Retry-After header', async () => { + const res = { header: jest.fn() }; + + await expect( + createGuard().rejectWith( + createContext(res), + detail({ timeToBlockExpire: 5 }), + ), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(res.header).toHaveBeenCalledWith('Retry-After', '5'); + }); + + it('rounds a sub-second wait up to one second', async () => { + const res = { header: jest.fn() }; + + await expect( + createGuard().rejectWith( + createContext(res), + detail({ timeToBlockExpire: 0.2 }), + ), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(res.header).toHaveBeenCalledWith('Retry-After', '1'); + }); + + it('rounds a fractional wait up so clients never retry too early', async () => { + const res = { header: jest.fn() }; + + await expect( + createGuard().rejectWith( + createContext(res), + detail({ timeToBlockExpire: 4.1 }), + ), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(res.header).toHaveBeenCalledWith('Retry-After', '5'); + }); + + it('falls back to req.url when originalUrl is absent', async () => { + const res = { header: jest.fn() }; + const guard = createGuard(); + const warn = jest + .spyOn(guard['logger'], 'warn') + .mockImplementation(() => undefined); + + await expect( + guard.rejectWith( + createContext(res, { method: 'GET', url: '/fallback' }), + detail(), + ), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('/fallback')); + }); + + it('logs a request with no method or url without crashing', async () => { + const res = { header: jest.fn() }; + const guard = createGuard(); + jest.spyOn(guard['logger'], 'warn').mockImplementation(() => undefined); + + await expect( + guard.rejectWith(createContext(res, {}), detail()), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(res.header).toHaveBeenCalledWith('Retry-After', '5'); + }); + + it('logs the offending client and route', async () => { + const res = { header: jest.fn() }; + const guard = createGuard(); + const warn = jest + .spyOn(guard['logger'], 'warn') + .mockImplementation(() => undefined); + + await expect( + guard.rejectWith( + createContext(res, { method: 'POST', originalUrl: '/orders' }), + detail({ tracker: '203.0.113.7' }), + ), + ).rejects.toBeInstanceOf(ThrottlerException); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('203.0.113.7')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('/orders')); + }); + }); +}); diff --git a/src/common/throttler/proxy-aware-throttler.guard.ts b/src/common/throttler/proxy-aware-throttler.guard.ts new file mode 100644 index 0000000..ad7bf79 --- /dev/null +++ b/src/common/throttler/proxy-aware-throttler.guard.ts @@ -0,0 +1,83 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler'; + +const IPV6_MAPPED_IPV4_PREFIX = '::ffff:'; + +/** Shared bucket for callers we cannot identify — deliberately restrictive. */ +export const UNKNOWN_TRACKER = 'unknown'; + +interface ThrottledRequest { + method?: string; + url?: string; + originalUrl?: string; +} + +interface ThrottledResponse { + header(name: string, value: string): void; +} + +export function normalizeIp(ip: string | undefined | null): string { + if (!ip) { + return UNKNOWN_TRACKER; + } + + const trimmed = ip.trim(); + if (trimmed.length === 0) { + return UNKNOWN_TRACKER; + } + + // Node reports IPv4 clients on a dual-stack socket as `::ffff:1.2.3.4`. + // Without collapsing them, one client can occupy two buckets. + const lower = trimmed.toLowerCase(); + return lower.startsWith(IPV6_MAPPED_IPV4_PREFIX) + ? lower.slice(IPV6_MAPPED_IPV4_PREFIX.length) + : lower; +} + +/** + * Adds three things the stock guard lacks: + * - a normalized, never-undefined tracker key; + * - a canonical `Retry-After` header (the base guard only emits the suffixed + * `Retry-After-` variants when throttlers are named); + * - a log line per rejection, so abuse is visible in the platform's logs. + * + * Client IPs come from `req.ip`, which honours `trust proxy`. That setting is + * driven by TRUST_PROXY_HOPS and defaults to 0, so `X-Forwarded-For` is ignored + * unless a proxy is explicitly declared. + */ +@Injectable() +export class ProxyAwareThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(ProxyAwareThrottlerGuard.name); + + protected getTracker(req: Record): Promise { + const socket = req.socket as { remoteAddress?: unknown } | undefined; + const ip: unknown = req.ip ?? socket?.remoteAddress; + + return Promise.resolve( + normalizeIp(typeof ip === 'string' ? ip : undefined), + ); + } + + protected async throwThrottlingException( + context: ExecutionContext, + detail: ThrottlerLimitDetail, + ): Promise { + const { req, res } = this.getRequestResponse(context) as { + req: ThrottledRequest; + res: ThrottledResponse; + }; + const retryAfterSeconds = Math.max(1, Math.ceil(detail.timeToBlockExpire)); + + // Must stay a method call: Express binds `this` on the response object. + res.header('Retry-After', String(retryAfterSeconds)); + + this.logger.warn( + `Rate limit exceeded: tracker=${detail.tracker} ` + + `method=${req.method ?? 'UNKNOWN'} ` + + `path=${req.originalUrl ?? req.url ?? ''} ` + + `hits=${detail.totalHits}/${detail.limit} retryAfter=${retryAfterSeconds}s`, + ); + + await super.throwThrottlingException(context, detail); + } +} diff --git a/src/common/throttler/throttler.config.spec.ts b/src/common/throttler/throttler.config.spec.ts new file mode 100644 index 0000000..04ea6a3 --- /dev/null +++ b/src/common/throttler/throttler.config.spec.ts @@ -0,0 +1,106 @@ +import { ThrottlerOptions } from '@nestjs/throttler'; +import { EnvironmentVariables, validateEnv } from '../../config/env.validation'; +import { + buildThrottlerOptions, + THROTTLER_LONG, + THROTTLER_MEDIUM, + THROTTLER_SHORT, +} from './throttler.config'; + +const configFor = (env: Record = {}): EnvironmentVariables => + validateEnv(env); + +const throttlersOf = (env: Record = {}): ThrottlerOptions[] => { + const options = buildThrottlerOptions(configFor(env)); + if (Array.isArray(options)) { + throw new Error('expected the object form of ThrottlerModuleOptions'); + } + return options.throttlers; +}; + +const byName = ( + throttlers: ThrottlerOptions[], + name: string, +): ThrottlerOptions => { + const found = throttlers.find((throttler) => throttler.name === name); + if (!found) { + throw new Error(`throttler "${name}" not configured`); + } + return found; +}; + +describe('buildThrottlerOptions', () => { + it('registers the short, medium and long windows', () => { + expect(throttlersOf().map((throttler) => throttler.name)).toEqual([ + THROTTLER_SHORT, + THROTTLER_MEDIUM, + THROTTLER_LONG, + ]); + }); + + it('uses the documented defaults', () => { + const throttlers = throttlersOf(); + + expect(byName(throttlers, THROTTLER_SHORT)).toMatchObject({ + ttl: 1_000, + limit: 10, + }); + expect(byName(throttlers, THROTTLER_MEDIUM)).toMatchObject({ + ttl: 10_000, + limit: 50, + }); + expect(byName(throttlers, THROTTLER_LONG)).toMatchObject({ + ttl: 60_000, + limit: 200, + }); + }); + + it('keeps every window narrower than the next one', () => { + const throttlers = throttlersOf(); + + for (let i = 1; i < throttlers.length; i += 1) { + expect(throttlers[i].ttl).toBeGreaterThan( + throttlers[i - 1].ttl as number, + ); + expect(throttlers[i].limit).toBeGreaterThan( + throttlers[i - 1].limit as number, + ); + } + }); + + it('reads overrides from the environment', () => { + const throttlers = throttlersOf({ + THROTTLE_SHORT_TTL_MS: '2000', + THROTTLE_SHORT_LIMIT: '5', + }); + + expect(byName(throttlers, THROTTLER_SHORT)).toMatchObject({ + ttl: 2_000, + limit: 5, + }); + }); + + it('leaves blockDuration unset so the window simply rolls over', () => { + expect( + byName(throttlersOf(), THROTTLER_SHORT).blockDuration, + ).toBeUndefined(); + }); + + it('applies a configured block duration to every window', () => { + const throttlers = throttlersOf({ THROTTLE_BLOCK_DURATION_MS: '30000' }); + + for (const throttler of throttlers) { + expect(throttler.blockDuration).toBe(30_000); + } + }); + + it('emits rate-limit headers and a non-leaking error message', () => { + const options = buildThrottlerOptions(configFor()); + if (Array.isArray(options)) { + throw new Error('expected the object form of ThrottlerModuleOptions'); + } + + expect(options.setHeaders).toBe(true); + expect(options.errorMessage).toContain('Too many requests'); + }); +}); diff --git a/src/common/throttler/throttler.config.ts b/src/common/throttler/throttler.config.ts new file mode 100644 index 0000000..e546253 --- /dev/null +++ b/src/common/throttler/throttler.config.ts @@ -0,0 +1,51 @@ +import { ThrottlerModuleOptions } from '@nestjs/throttler'; +import { EnvironmentVariables } from '../../config/env.validation'; + +export const THROTTLER_SHORT = 'short'; +export const THROTTLER_MEDIUM = 'medium'; +export const THROTTLER_LONG = 'long'; + +export const THROTTLER_ERROR_MESSAGE = + 'Too many requests. Please retry after the period indicated by the Retry-After header.'; + +/** + * Three stacked windows, all of which must pass. A single window can either be + * tight enough to stop a burst or loose enough to allow normal sustained + * traffic, but not both; layering them covers each case without punishing + * legitimate clients. + * + * Storage is the in-memory default, so limits are per instance. Running more + * than one replica multiplies the effective limit — see docs/security.md for + * the shared-storage migration path. + */ +export function buildThrottlerOptions( + config: EnvironmentVariables, +): ThrottlerModuleOptions { + const blockDuration = config.THROTTLE_BLOCK_DURATION_MS || undefined; + + return { + errorMessage: THROTTLER_ERROR_MESSAGE, + // Emits X-RateLimit-* so clients can back off before they get a 429. + setHeaders: true, + throttlers: [ + { + name: THROTTLER_SHORT, + ttl: config.THROTTLE_SHORT_TTL_MS, + limit: config.THROTTLE_SHORT_LIMIT, + blockDuration, + }, + { + name: THROTTLER_MEDIUM, + ttl: config.THROTTLE_MEDIUM_TTL_MS, + limit: config.THROTTLE_MEDIUM_LIMIT, + blockDuration, + }, + { + name: THROTTLER_LONG, + ttl: config.THROTTLE_LONG_TTL_MS, + limit: config.THROTTLE_LONG_LIMIT, + blockDuration, + }, + ], + }; +} diff --git a/src/config/app-config.module.ts b/src/config/app-config.module.ts new file mode 100644 index 0000000..efa5ada --- /dev/null +++ b/src/config/app-config.module.ts @@ -0,0 +1,34 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EnvironmentVariables, validateEnv } from './env.validation'; + +/** + * Publishes the validated environment as an injectable, strongly typed object. + * + * The validation deliberately runs inside a provider factory rather than in + * `ConfigModule.forRoot({ validate })`: the latter executes while the module + * file is being imported, which pins the configuration before the process is + * fully set up and makes it impossible to boot the same module twice with + * different settings. Resolving at DI time keeps the fail-fast behaviour — an + * invalid value still aborts startup — without that coupling. + * + * `ConfigModule` is kept purely for `.env` file loading in local development. + */ +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + cache: true, + expandVariables: true, + }), + ], + providers: [ + { + provide: EnvironmentVariables, + useFactory: (): EnvironmentVariables => validateEnv(process.env), + }, + ], + exports: [EnvironmentVariables], +}) +export class AppConfigModule {} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..23f6087 --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,141 @@ +import { Environment, validateEnv } from './env.validation'; + +describe('validateEnv', () => { + describe('defaults', () => { + it('applies safe defaults when nothing is configured', () => { + const config = validateEnv({}); + + expect(config.NODE_ENV).toBe(Environment.Development); + expect(config.PORT).toBe(3000); + expect(config.TRUST_PROXY_HOPS).toBe(0); + expect(config.CORS_ORIGINS).toBe(''); + expect(config.CORS_CREDENTIALS).toBe(false); + expect(config.BODY_LIMIT).toBe('100kb'); + }); + + it('defaults to trusting no proxy so X-Forwarded-For cannot be spoofed', () => { + expect(validateEnv({}).TRUST_PROXY_HOPS).toBe(0); + }); + }); + + describe('numeric coercion', () => { + it('parses numeric strings into numbers', () => { + const config = validateEnv({ PORT: '8080', THROTTLE_SHORT_LIMIT: '25' }); + + expect(config.PORT).toBe(8080); + expect(config.THROTTLE_SHORT_LIMIT).toBe(25); + }); + + it('rejects partially numeric values instead of truncating them', () => { + expect(() => validateEnv({ PORT: '3000abc' })).toThrow( + /Invalid environment configuration/, + ); + }); + + it('rejects a port outside the valid range', () => { + expect(() => validateEnv({ PORT: '70000' })).toThrow( + /Invalid environment configuration/, + ); + }); + + it('rejects a negative proxy hop count', () => { + expect(() => validateEnv({ TRUST_PROXY_HOPS: '-1' })).toThrow( + /Invalid environment configuration/, + ); + }); + + it('accepts a value that is already a number', () => { + expect(validateEnv({ PORT: 8080 }).PORT).toBe(8080); + }); + + it('treats a blank value as unset', () => { + expect(validateEnv({ PORT: ' ' }).PORT).toBe(3000); + expect(validateEnv({ PORT: '' }).PORT).toBe(3000); + }); + + it('treats a blank boolean as unset rather than false', () => { + expect(validateEnv({ HSTS_ENABLED: '' }).HSTS_ENABLED).toBe(true); + }); + + it('rejects a zero rate-limit budget', () => { + expect(() => validateEnv({ THROTTLE_SHORT_LIMIT: '0' })).toThrow( + /Invalid environment configuration/, + ); + }); + }); + + describe('boolean coercion', () => { + it.each([ + ['true', true], + ['TRUE', true], + ['1', true], + ['yes', true], + ['on', true], + ])('reads %s as true', (raw, expected) => { + expect(validateEnv({ CORS_CREDENTIALS: raw }).CORS_CREDENTIALS).toBe( + expected, + ); + }); + + it.each([ + ['false', false], + ['FALSE', false], + ['0', false], + ['no', false], + ['off', false], + ])('reads %s as false rather than truthy', (raw, expected) => { + expect(validateEnv({ HSTS_ENABLED: raw }).HSTS_ENABLED).toBe(expected); + }); + + it('accepts a value that is already a boolean', () => { + expect(validateEnv({ HSTS_ENABLED: false }).HSTS_ENABLED).toBe(false); + }); + + it('rejects a value that is neither truthy nor falsy', () => { + expect(() => validateEnv({ HSTS_ENABLED: 'maybe' })).toThrow( + /Invalid environment configuration/, + ); + }); + }); + + describe('NODE_ENV', () => { + it.each(['development', 'test', 'staging', 'production'])( + 'accepts %s', + (env) => { + expect(validateEnv({ NODE_ENV: env }).NODE_ENV).toBe(env); + }, + ); + + it('rejects an unknown environment', () => { + expect(() => validateEnv({ NODE_ENV: 'prod' })).toThrow( + /Invalid environment configuration/, + ); + }); + }); + + describe('BODY_LIMIT', () => { + it.each(['100kb', '1mb', '512b', '1.5mb', '2GB'])('accepts %s', (limit) => { + expect(validateEnv({ BODY_LIMIT: limit }).BODY_LIMIT).toBe(limit); + }); + + it.each(['100', 'huge', '10 mb', '1tb'])('rejects %s', (limit) => { + expect(() => validateEnv({ BODY_LIMIT: limit })).toThrow( + /BODY_LIMIT must be a byte size/, + ); + }); + }); + + it('reports every problem at once', () => { + expect(() => validateEnv({ PORT: 'x', NODE_ENV: 'nope' })).toThrow( + /Invalid environment configuration/, + ); + }); + + it('leaves unrelated environment variables untouched', () => { + const config = validateEnv({ + SOME_OTHER_VAR: 'value', + }) as unknown as Record; + + expect(config.SOME_OTHER_VAR).toBe('value'); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..bb7891f --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,198 @@ +import { + plainToInstance, + Transform, + TransformFnParams, +} from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsString, + Matches, + Max, + Min, + validateSync, +} from 'class-validator'; + +export enum Environment { + Development = 'development', + Test = 'test', + Staging = 'staging', + Production = 'production', +} + +const TRUE_VALUES = ['true', '1', 'yes', 'on']; +const FALSE_VALUES = ['false', '0', 'no', 'off']; + +/** + * Env vars always arrive as strings. `enableImplicitConversion` is deliberately + * avoided because `Boolean('false')` is `true`, which would silently turn every + * opt-out flag into an opt-in. + */ +const toBoolean = ({ value }: TransformFnParams): unknown => { + if (typeof value !== 'string') { + return value; + } + const normalized = value.trim().toLowerCase(); + if (TRUE_VALUES.includes(normalized)) { + return true; + } + if (FALSE_VALUES.includes(normalized)) { + return false; + } + return value; +}; + +const toInt = ({ value }: TransformFnParams): unknown => { + if (typeof value !== 'string') { + return value; + } + // `Number` (unlike parseInt) rejects '10abc' instead of silently reading 10. + const parsed = Number(value.trim()); + return Number.isNaN(parsed) ? value : parsed; +}; + +/** + * A variable set to an empty or whitespace-only value is treated as unset, so + * the declared default applies. Shells and `.env` files both produce `KEY=` + * routinely, and crashing on it — or, worse, reading it as `false` — is a + * surprising way to fail. + */ +function withoutBlankValues( + config: Record, +): Record { + return Object.fromEntries( + Object.entries(config).filter( + ([, value]) => !(typeof value === 'string' && value.trim() === ''), + ), + ); +} + +const ONE_MINUTE_MS = 60_000; +const ONE_DAY_SECONDS = 86_400; +const ONE_YEAR_SECONDS = 31_536_000; + +export class EnvironmentVariables { + @IsEnum(Environment) + NODE_ENV: Environment = Environment.Development; + + @Transform(toInt) + @IsInt() + @Min(0) + @Max(65_535) + PORT = 3000; + + /** + * Number of reverse proxies in front of the app. Anything above 0 makes + * Express read `X-Forwarded-For`, so an over-count lets a client spoof its own + * IP and walk straight past the rate limiter. Default 0 = trust nobody. + */ + @Transform(toInt) + @IsInt() + @Min(0) + @Max(10) + TRUST_PROXY_HOPS = 0; + + /** Comma-separated allowlist. Empty disables CORS entirely (same-origin only). */ + @IsString() + CORS_ORIGINS = ''; + + @Transform(toBoolean) + @IsBoolean() + CORS_CREDENTIALS = false; + + @Transform(toInt) + @IsInt() + @Min(0) + @Max(ONE_DAY_SECONDS) + CORS_MAX_AGE_SECONDS = 600; + + /** Burst guard: blocks hammering from a single client. */ + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_SHORT_TTL_MS = 1_000; + + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_SHORT_LIMIT = 10; + + /** Sustained-use guard over a short window. */ + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_MEDIUM_TTL_MS = 10_000; + + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_MEDIUM_LIMIT = 50; + + /** Overall budget per client per minute. */ + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_LONG_TTL_MS = ONE_MINUTE_MS; + + @Transform(toInt) + @IsInt() + @Min(1) + THROTTLE_LONG_LIMIT = 200; + + /** How long a client stays blocked after tripping a limit. 0 = until the window rolls over. */ + @Transform(toInt) + @IsInt() + @Min(0) + THROTTLE_BLOCK_DURATION_MS = 0; + + /** Body size ceiling accepted by the JSON/urlencoded parsers. */ + @IsString() + @Matches(/^\d+(\.\d+)?(b|kb|mb|gb)$/i, { + message: 'BODY_LIMIT must be a byte size such as "100kb" or "1mb"', + }) + BODY_LIMIT = '100kb'; + + @Transform(toBoolean) + @IsBoolean() + HSTS_ENABLED = true; + + @Transform(toInt) + @IsInt() + @Min(0) + @Max(ONE_YEAR_SECONDS * 2) + HSTS_MAX_AGE_SECONDS = ONE_YEAR_SECONDS; + + @Transform(toBoolean) + @IsBoolean() + COMPRESSION_ENABLED = true; +} + +/** + * Fail-fast validation wired into `ConfigModule.forRoot({ validate })`. + * A misconfigured security knob must crash on boot, never degrade silently. + */ +export function validateEnv( + config: Record, +): EnvironmentVariables { + const validated = plainToInstance( + EnvironmentVariables, + withoutBlankValues(config), + { excludeExtraneousValues: false, exposeDefaultValues: true }, + ); + + const errors = validateSync(validated, { + skipMissingProperties: false, + whitelist: false, + forbidUnknownValues: false, + }); + + if (errors.length > 0) { + const details = errors + .map((error) => Object.values(error.constraints ?? {}).join(', ')) + .join('; '); + throw new Error(`Invalid environment configuration: ${details}`); + } + + return validated; +} diff --git a/src/main.ts b/src/main.ts index f76bc8d..8ed41dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,23 @@ +import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; +import { configureSecurity } from './common/security/configure-security'; +import { EnvironmentVariables } from './config/env.validation'; -async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { + // Parsers are re-registered by `configureSecurity` with an explicit size + // limit; the defaults are unbounded enough to be worth replacing. + bodyParser: false, + }); + + configureSecurity(app); + + const { PORT } = app.get(EnvironmentVariables); + await app.listen(PORT); + + new Logger('Bootstrap').log(`Listening on port ${PORT}`); } -bootstrap(); + +void bootstrap(); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..c535020 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,19 +1,16 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; +import { createTestApp } from './utils/create-test-app'; describe('AppController (e2e)', () => { - let app: INestApplication; + let app: NestExpressApplication; beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + app = await createTestApp(); + }); - app = moduleFixture.createNestApplication(); - await app.init(); + afterEach(async () => { + await app.close(); }); it('/ (GET)', () => { diff --git a/test/security.e2e-spec.ts b/test/security.e2e-spec.ts new file mode 100644 index 0000000..be700b7 --- /dev/null +++ b/test/security.e2e-spec.ts @@ -0,0 +1,311 @@ +import { NestExpressApplication } from '@nestjs/platform-express'; +import request from 'supertest'; +import { createTestApp } from './utils/create-test-app'; + +/** Wide enough that only the window under test can trip. */ +const RELAXED_LIMITS = { + THROTTLE_SHORT_LIMIT: '1000', + THROTTLE_MEDIUM_LIMIT: '1000', + THROTTLE_LONG_LIMIT: '1000', +}; + +describe('Security (e2e)', () => { + let app: NestExpressApplication; + + afterEach(async () => { + await app?.close(); + }); + + describe('security headers', () => { + beforeEach(async () => { + app = await createTestApp(RELAXED_LIMITS); + }); + + it('locks the CSP down to nothing for a JSON API', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['content-security-policy']).toContain( + "default-src 'none'", + ); + expect(response.headers['content-security-policy']).toContain( + "frame-ancestors 'none'", + ); + }); + + it('blocks framing and MIME sniffing', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['x-frame-options']).toBe('DENY'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('sends no referrer', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['referrer-policy']).toBe('no-referrer'); + }); + + it('enables HSTS', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['strict-transport-security']).toContain( + 'max-age=31536000', + ); + expect(response.headers['strict-transport-security']).toContain( + 'includeSubDomains', + ); + }); + + it('does not advertise the server technology', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers).not.toHaveProperty('x-powered-by'); + }); + }); + + describe('request correlation', () => { + beforeEach(async () => { + app = await createTestApp(RELAXED_LIMITS); + }); + + it('assigns an id when the client sends none', async () => { + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it('echoes a well-formed client id', async () => { + const clientId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + + const response = await request(app.getHttpServer()) + .get('/') + .set('X-Request-Id', clientId); + + expect(response.headers['x-request-id']).toBe(clientId); + }); + + it('replaces a malformed client id instead of reflecting it', async () => { + const response = await request(app.getHttpServer()) + .get('/') + .set('X-Request-Id', 'not-a-uuid'); + + expect(response.headers['x-request-id']).not.toBe('not-a-uuid'); + }); + }); + + describe('rate limiting', () => { + it('reports the remaining budget on every response', async () => { + app = await createTestApp(RELAXED_LIMITS); + + const response = await request(app.getHttpServer()).get('/'); + + expect(response.headers['x-ratelimit-limit-short']).toBe('1000'); + expect(response.headers['x-ratelimit-remaining-short']).toBe('999'); + }); + + it('rejects a burst above the short window with 429', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + THROTTLE_SHORT_LIMIT: '3', + THROTTLE_SHORT_TTL_MS: '10000', + }); + + const server = app.getHttpServer(); + for (let i = 0; i < 3; i += 1) { + await request(server).get('/').expect(200); + } + + const blocked = await request(server).get('/'); + + expect(blocked.status).toBe(429); + }); + + it('tells the client when to retry', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + THROTTLE_SHORT_LIMIT: '1', + THROTTLE_SHORT_TTL_MS: '10000', + }); + + const server = app.getHttpServer(); + await request(server).get('/').expect(200); + const blocked = await request(server).get('/'); + + expect(blocked.status).toBe(429); + expect(Number(blocked.headers['retry-after'])).toBeGreaterThan(0); + }); + + it('returns a structured error body without internals', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + THROTTLE_SHORT_LIMIT: '1', + THROTTLE_SHORT_TTL_MS: '10000', + }); + + const server = app.getHttpServer(); + await request(server).get('/').expect(200); + const blocked = await request(server).get('/'); + + expect(blocked.body).toMatchObject({ + statusCode: 429, + message: expect.stringContaining('Too many requests') as string, + path: '/', + }); + expect(blocked.body).toHaveProperty('requestId'); + expect(JSON.stringify(blocked.body)).not.toContain('stack'); + }); + + it('enforces the long window even when the short one is satisfied', async () => { + app = await createTestApp({ + THROTTLE_SHORT_LIMIT: '1000', + THROTTLE_MEDIUM_LIMIT: '1000', + THROTTLE_LONG_LIMIT: '2', + THROTTLE_LONG_TTL_MS: '60000', + }); + + const server = app.getHttpServer(); + await request(server).get('/').expect(200); + await request(server).get('/').expect(200); + + expect((await request(server).get('/')).status).toBe(429); + }); + + it('ignores a spoofed X-Forwarded-For when no proxy is trusted', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + THROTTLE_SHORT_LIMIT: '2', + THROTTLE_SHORT_TTL_MS: '10000', + TRUST_PROXY_HOPS: '0', + }); + + const server = app.getHttpServer(); + await request(server).get('/').set('X-Forwarded-For', '1.1.1.1'); + await request(server).get('/').set('X-Forwarded-For', '2.2.2.2'); + + // A rotating forwarded-for header must not buy extra requests. + const blocked = await request(server) + .get('/') + .set('X-Forwarded-For', '3.3.3.3'); + + expect(blocked.status).toBe(429); + }); + + it('honours X-Forwarded-For when a proxy is declared', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + THROTTLE_SHORT_LIMIT: '1', + THROTTLE_SHORT_TTL_MS: '10000', + TRUST_PROXY_HOPS: '1', + }); + + const server = app.getHttpServer(); + await request(server) + .get('/') + .set('X-Forwarded-For', '1.1.1.1') + .expect(200); + + // Different real client behind the same proxy: its own budget. + const other = await request(server) + .get('/') + .set('X-Forwarded-For', '2.2.2.2'); + + expect(other.status).toBe(200); + + // Same client again: over its own limit. + const repeat = await request(server) + .get('/') + .set('X-Forwarded-For', '1.1.1.1'); + + expect(repeat.status).toBe(429); + }); + }); + + describe('CORS', () => { + it('does not answer cross-origin requests by default', async () => { + app = await createTestApp(RELAXED_LIMITS); + + const response = await request(app.getHttpServer()) + .get('/') + .set('Origin', 'https://evil.example.com'); + + expect(response.headers).not.toHaveProperty( + 'access-control-allow-origin', + ); + }); + + it('allows an allowlisted origin', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + CORS_ORIGINS: 'https://app.example.com', + }); + + const response = await request(app.getHttpServer()) + .get('/') + .set('Origin', 'https://app.example.com'); + + expect(response.headers['access-control-allow-origin']).toBe( + 'https://app.example.com', + ); + }); + + it('rejects an origin outside the allowlist', async () => { + app = await createTestApp({ + ...RELAXED_LIMITS, + CORS_ORIGINS: 'https://app.example.com', + }); + + const response = await request(app.getHttpServer()) + .get('/') + .set('Origin', 'https://evil.example.com'); + + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + }); + }); + + describe('request payloads', () => { + beforeEach(async () => { + app = await createTestApp({ ...RELAXED_LIMITS, BODY_LIMIT: '1kb' }); + }); + + it('refuses a body larger than the configured limit', async () => { + const response = await request(app.getHttpServer()) + .post('/') + .set('Content-Type', 'application/json') + .send({ payload: 'x'.repeat(4096) }); + + expect(response.status).toBe(413); + }); + + it('accepts a body within the limit', async () => { + const response = await request(app.getHttpServer()) + .post('/') + .set('Content-Type', 'application/json') + .send({ payload: 'x'.repeat(16) }); + + // No POST route exists, but the payload made it past the parser. + expect(response.status).toBe(404); + }); + }); + + describe('error responses', () => { + beforeEach(async () => { + app = await createTestApp(RELAXED_LIMITS); + }); + + it('wraps a 404 in the standard envelope', async () => { + const response = await request(app.getHttpServer()).get( + '/does-not-exist', + ); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + statusCode: 404, + path: '/does-not-exist', + }); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).toHaveProperty('requestId'); + }); + }); +}); diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts new file mode 100644 index 0000000..2e89ae1 --- /dev/null +++ b/test/utils/create-test-app.ts @@ -0,0 +1,35 @@ +import { NestExpressApplication } from '@nestjs/platform-express'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../../src/app.module'; +import { configureSecurity } from '../../src/common/security/configure-security'; + +/** + * Boots the application exactly as `main.ts` does, so the e2e suite exercises + * the real hardening rather than a bare Nest app. + */ +export async function createTestApp( + env: Record = {}, +): Promise { + const previousEnv = { ...process.env }; + Object.assign(process.env, env); + + try { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestApplication({ + bodyParser: false, + logger: false, + }); + + configureSecurity(app); + await app.init(); + + return app; + } finally { + // Config is read at module init, so the override is no longer needed and + // must not leak into the next test. + process.env = previousEnv; + } +}