Skip to content

feat(security): add rate limiting and transport hardening - #3

Open
fondomp wants to merge 1 commit into
mainfrom
seguridad-en-el-codigo
Open

feat(security): add rate limiting and transport hardening#3
fondomp wants to merge 1 commit into
mainfrom
seguridad-en-el-codigo

Conversation

@fondomp

@fondomp fondomp commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

Hardens the API before it handles real traffic: rate limiting plus a transport-security layer (Helmet, CORS, payload limits, input validation, non-leaking errors).

Every protection is applied in one place — configure-security.ts — which both main.ts and the e2e suite call, so the tests exercise the real configuration rather than an approximation of it.

Rate limiting

Three stacked windows, all of which must pass:

Window TTL Limit
short 1s 10
medium 10s 50
long 60s 200

A single window can either be tight enough to stop a burst or loose enough to allow normal sustained traffic, but not both.

ProxyAwareThrottlerGuard is registered as an APP_GUARD (every route by default; opt out with @SkipThrottle(), tighten with @Throttle()). It adds three things the stock guard lacks:

  • a normalized, never-undefined tracker — IPv6-mapped IPv4 (::ffff:1.2.3.4) is collapsed so one client cannot occupy two buckets;
  • a canonical Retry-After header — the base guard only emits the suffixed Retry-After-<window> variants when throttlers are named;
  • a warn log per rejection, so abuse is visible.

Transport security

  • Helmet — API-appropriate CSP (every fetch directive 'none'), nosniff, frame-ancestors 'none', no-referrer, HSTS, X-Powered-By removed.
  • CORS — off unless CORS_ORIGINS declares an exact allowlist. * with credentials throws at boot; browsers reject that pairing anyway, and failing early beats debugging silently dropped credentialed requests.
  • Payload limits — both parsers capped at BODY_LIMIT (default 100kb); oversized payloads get 413.
  • Validation — global ValidationPipe with whitelist + forbidNonWhitelisted, so mass-assignment and unexpected-field attacks stop at the boundary.
  • ErrorsAllExceptionsFilter returns a fixed envelope and never leaks stack traces, ORM errors or driver messages. Client-error statuses raised by Express middleware (body parser, CORS) are preserved, but the library's own message is replaced with the standard reason phrase; a library 5xx is reported as a plain 500.
  • Correlation — validated X-Request-Id on every request. A client 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 path.

Configuration

All settings are environment variables validated at startup by env.validation.ts — an invalid security knob aborts the boot instead of degrading silently. A blank value is treated as unset, so the declared default applies. See .env.example.

Validation runs in a provider factory rather than ConfigModule.forRoot({ validate }), because the latter executes at module-import time and pins the configuration before the process is set up — which also made it impossible to boot the module twice with different settings in tests. Fail-fast behaviour is unchanged.

Two decisions needed per environment

  1. TRUST_PROXY_HOPS (default 0). Set it to the real number of proxies in front of the service. Too high and a client can prepend a forged X-Forwarded-For, get a fresh bucket every request and bypass rate limiting entirely. Both behaviours are covered by e2e tests.
  2. Throttler storage is in-memory, so limits are per replica — running N replicas multiplies the effective limit by N. docs/security.md documents the shared-storage migration path; the options already use the object form, so adding a Redis adapter is a one-line change.

Test plan

  • pnpm run lint — clean
  • pnpm run build — clean
  • pnpm run test — 124 unit tests pass
  • pnpm run test:e2e — 22 e2e tests pass
  • pnpm run coverage — 100% statements / 93% branches over non-wiring code; an 85% threshold is now enforced (main.ts and *.module.ts excluded as DI wiring, covered by e2e)
  • Smoke-tested the built app: 10 requests through then 429 with Retry-After, expected security headers present, 413 on an oversized body, no server errors in the log

e2e coverage includes the security headers, rate-limit headers and 429 envelope, X-Forwarded-For spoofing both with and without a declared proxy, CORS on/off/rejected-origin, the body limit, and the 404 envelope.

Notes

  • Adds runtime deps: @nestjs/throttler, @nestjs/config, helmet, compression, class-validator, class-transformer.
  • No secrets are introduced; .env.example contains placeholders and comments only.
  • This layer is transport hardening — it does not add authentication or authorisation. Auth0 via auth-middleware-ts still needs to be wired before the service handles real data.
  • TechDocs setup (mkdocs.yml, catalog-info.yaml, publish-techdocs.yml) is intentionally out of scope; the repo is not yet catalogued in kira. docs/security.md is written to slot straight into a docs/ tree when it is.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants