Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down
179 changes: 179 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -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-<window>` 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.
20 changes: 19 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading