From 7218b251231d6a87e72a904c0f9a992605ac8ddc Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 15 Jun 2026 19:56:06 +0100 Subject: [PATCH] Harden security headers and tune rate limiter --- .env.example | 11 ++++++++- package-lock.json | 1 + readme.md | 15 ++++++++++-- src/config/env.ts | 41 +++++++++++++++++++++++++++++++ src/index.ts | 12 +++++----- src/middleware/index.ts | 3 ++- src/middleware/rateLimiter.ts | 31 +++++++++++++++++++++++- src/middleware/security.ts | 45 +++++++++++++++++++++++++++++++++++ 8 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 src/middleware/security.ts diff --git a/.env.example b/.env.example index a847c4e..0b8bb6c 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ DB_PASSWORD=password DB_CONTAINER_NAME=neurowealth_db # Security — Rate limiting -# Global limiter +# Global limiter (public read APIs, portfolio, vault, etc.) RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX=100 # Auth endpoints — stricter to resist credential stuffing (15 min window, 20 req) @@ -51,10 +51,19 @@ ADMIN_RATE_LIMIT_MAX=10 # Internal/agent service endpoints — higher throughput (1 min window, 500 req) INTERNAL_RATE_LIMIT_WINDOW_MS=60000 INTERNAL_RATE_LIMIT_MAX=500 +# Public webhooks — unauthenticated inbound callbacks (1 min window, 30 req) +WEBHOOK_RATE_LIMIT_WINDOW_MS=60000 +WEBHOOK_RATE_LIMIT_MAX=30 # Trusted-IP bypass: comma-separated IPs that skip all rate limits TRUSTED_IPS= # Internal service token: value expected in X-Internal-Token request header INTERNAL_SERVICE_TOKEN= +# Security — Reverse proxy +# Express trust proxy: hop count behind your load balancer (default 1). +# Use 0/false when the app is exposed directly; use 2 behind CDN + LB. +# Comma-separated keywords also supported: loopback,linklocal,uniquelocal +TRUST_PROXY=1 + # Dead Letter Queue DLQ_ALERT_THRESHOLD=50 diff --git a/package-lock.json b/package-lock.json index 113e3f6..2724ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4448,6 +4448,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/readme.md b/readme.md index 89a458c..94173de 100644 --- a/readme.md +++ b/readme.md @@ -95,17 +95,28 @@ npm start Rate limiting ------------- -The API applies layered rate limits (all configurable via `.env`): +The API applies layered rate limits (all configurable via `.env`). Health probe paths (`/health/live`, `/health/ready`, `/health/*`) are exempt from the global limiter so Kubernetes and load-balancer checks are not throttled. | Limiter | Routes | Default | Env vars | |---------|--------|---------|----------| -| Global | All routes | 100 req / 15 min | `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` | +| Global | All routes except health probes | 100 req / 15 min | `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` | | Auth | `/api/auth/*` | 20 req / 15 min | `AUTH_RATE_LIMIT_MAX`, `AUTH_RATE_LIMIT_WINDOW_MS` | | Admin | `/api/admin/*` | 10 req / 15 min | `ADMIN_RATE_LIMIT_MAX`, `ADMIN_RATE_LIMIT_WINDOW_MS` | | Internal | `/api/agent/*` | 500 req / 1 min | `INTERNAL_RATE_LIMIT_MAX`, `INTERNAL_RATE_LIMIT_WINDOW_MS` | +| Webhook | `/api/whatsapp/*` | 30 req / 1 min | `WEBHOOK_RATE_LIMIT_MAX`, `WEBHOOK_RATE_LIMIT_WINDOW_MS` | + +Public unauthenticated read endpoints (`/api/protocols/*`, `/api/vault/state`, `/api/stellar/*`, `/api/analytics/protocol-performance`) are covered by the global limiter. Authenticated routes stack the global limiter with JWT validation. **Bypass (trusted services only):** set `TRUSTED_IPS` to a comma-separated allowlist of IPs, or send the shared secret in the `X-Internal-Token` header (`INTERNAL_SERVICE_TOKEN`). Mount order matters: the bypass middleware runs before limiters in `src/index.ts`. +Trust proxy +----------- +When the API runs behind Nginx, Cloudflare, AWS ALB, Heroku, or Kubernetes ingress, set `TRUST_PROXY` so Express reads the real client IP from `X-Forwarded-For` (used by rate limiting and logging). Default is `1` (one reverse-proxy hop). Use `0` or `false` when the process is exposed directly; use `2` when traffic passes through CDN + load balancer. See `.env.example` for supported values. + +Security headers (Helmet) +------------------------- +In production, Helmet sets strict headers: CSP (`default-src 'none'`), HSTS (1 year, includeSubDomains, preload), `Referrer-Policy: no-referrer`, and cross-origin policies. Development and test disable CSP/HSTS so local tooling is not blocked. Configuration lives in `src/middleware/security.ts`. + For production secret handling, migrations, and rollback steps see `docs/PRODUCTION_DEPLOYMENT.md`. Testing diff --git a/src/config/env.ts b/src/config/env.ts index ee5da13..064c7a8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -96,6 +96,36 @@ function parseByteLimit(value: string | undefined, fallback: string): string { return value && /^\d+(kb|mb|b)?$/i.test(value) ? value : fallback } +/** + * Parse `TRUST_PROXY` for Express `app.set('trust proxy', …)`. + * + * Supported values: + * (unset) → 1 (single reverse-proxy hop — Nginx, ALB, Heroku, etc.) + * false | 0 → do not trust X-Forwarded-* headers + * true → trust all hops (not recommended in production) + * → trust that many proxy hops + * loopback → trust loopback addresses + * loopback,linklocal → comma-separated Express trust-proxy keywords or IPs + */ +function parseTrustProxy( + value: string | undefined +): boolean | number | string | string[] { + if (!value?.trim()) return 1 + + const trimmed = value.trim().toLowerCase() + if (trimmed === 'false' || trimmed === '0') return false + if (trimmed === 'true') return true + if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10) + if (value.includes(',')) { + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + + return value.trim() +} + /** * Validate Stellar secret key format and warn on mainnet in dev. */ @@ -204,6 +234,17 @@ export const config = { windowMs: parseInt(process.env.INTERNAL_RATE_LIMIT_WINDOW_MS || '60000'), max: parseInt(process.env.INTERNAL_RATE_LIMIT_MAX || '500'), }, + /** Public webhook endpoints — resist spoofed / replay floods (e.g. Twilio) */ + webhookRateLimit: { + windowMs: parseInt(process.env.WEBHOOK_RATE_LIMIT_WINDOW_MS || '60000'), + max: parseInt(process.env.WEBHOOK_RATE_LIMIT_MAX || '30'), + }, + /** + * Express `trust proxy` setting — required for correct `req.ip` behind + * Nginx, Cloudflare, AWS ALB, Heroku, Kubernetes ingress, etc. + * See `parseTrustProxy` for accepted `TRUST_PROXY` values. + */ + trustProxy: parseTrustProxy(process.env.TRUST_PROXY), /** * TRUSTED_IPS: comma-separated list of IPv4/IPv6 addresses that bypass rate * limiting entirely (e.g. your CI runner, internal health-check probe). diff --git a/src/index.ts b/src/index.ts index 22750e9..d81f328 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ import { type Server } from 'node:http' import express from 'express' -import helmet from 'helmet' import { config } from './config/env' import { errorHandler } from './middleware/errorHandler' import { requestLogger } from './middleware/logger' -import { rateLimiter, authRateLimiter, adminRateLimiter, internalRateLimiter, trustedIpBypass } from './middleware/rateLimiter' +import { rateLimiter, authRateLimiter, adminRateLimiter, internalRateLimiter, webhookRateLimiter, trustedIpBypass } from './middleware/rateLimiter' +import { configureTrustProxy, securityHeaders } from './middleware/security' import { logger } from './utils/logger' import { startAgentLoop, stopAgentLoop } from './agent/loop' import { connectDb } from './db' @@ -54,11 +54,11 @@ function allServicesReady(): boolean { const app = express() -// Trust proxy — required for correct client IP behind Nginx / Cloudflare / Heroku -app.set('trust proxy', 1) +// Trust proxy — required for correct client IP behind Nginx / Cloudflare / Heroku / K8s ingress +configureTrustProxy(app) // ── Security and parsing middleware ──────────────────────────────────────── -app.use(helmet()) +app.use(securityHeaders()) // CORS — must come before body parsers so pre-flight OPTIONS is handled app.use(corsMiddleware) @@ -111,7 +111,7 @@ app.get('/health/ready', (_req, res) => { app.use('/health', healthRouter) app.use('/api/agent', internalRateLimiter, agentRouter) app.use('/api/auth', authRateLimiter, authRouter) -app.use('/api/whatsapp', whatsappRouter) +app.use('/api/whatsapp', webhookRateLimiter, whatsappRouter) app.use('/api/portfolio', portfolioRouter) app.use('/api/transactions', transactionsRouter) app.use('/api/protocols', protocolsRouter) diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 205c091..e74e385 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,3 +1,4 @@ export { logger } from "../utils/logger"; export { errorHandler } from "./errorHandler"; -export { rateLimiter } from "./rateLimiter"; \ No newline at end of file +export { rateLimiter } from "./rateLimiter"; +export { configureTrustProxy, securityHeaders } from "./security"; \ No newline at end of file diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 152ce35..30a7b5a 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -35,6 +35,20 @@ function isTrusted(req: Request): boolean { return req.res?.locals['trusted'] === true } +/** K8s / load-balancer probes must not consume the global rate-limit budget. */ +function isHealthProbe(req: Request): boolean { + return ( + req.path === '/health/live' || + req.path === '/health/ready' || + req.path === '/health' || + req.path.startsWith('/health/') + ) +} + +function skipUnlessLimited(req: Request): boolean { + return isTrusted(req) || isHealthProbe(req) +} + // ── Rate limiters ────────────────────────────────────────────────────────── /** @@ -46,7 +60,7 @@ export const rateLimiter = rateLimit({ max: config.security.rateLimit.max, standardHeaders: true, legacyHeaders: false, - skip: isTrusted, + skip: skipUnlessLimited, message: { error: 'Too many requests. Please try again later.', }, @@ -82,6 +96,21 @@ export const adminRateLimiter = rateLimit({ }, }) +/** + * Webhook rate limiter — applied to unauthenticated inbound webhooks. + * Defaults: 30 req / 1 min (env: WEBHOOK_RATE_LIMIT_MAX / WEBHOOK_RATE_LIMIT_WINDOW_MS). + */ +export const webhookRateLimiter = rateLimit({ + windowMs: config.security.webhookRateLimit.windowMs, + max: config.security.webhookRateLimit.max, + standardHeaders: true, + legacyHeaders: false, + skip: isTrusted, + message: { + error: 'Too many webhook requests. Please try again later.', + }, +}) + /** * Internal / agent rate limiter — higher throughput for service-to-service calls. * Defaults: 500 req / 1 min (env: INTERNAL_RATE_LIMIT_MAX / INTERNAL_RATE_LIMIT_WINDOW_MS). diff --git a/src/middleware/security.ts b/src/middleware/security.ts new file mode 100644 index 0000000..2b4427f --- /dev/null +++ b/src/middleware/security.ts @@ -0,0 +1,45 @@ +import { type Express } from 'express' +import helmet from 'helmet' +import { config } from '../config/env' + +/** + * Apply Express `trust proxy` so `req.ip`, `req.protocol`, and rate-limit + * keys reflect the real client when the app sits behind a reverse proxy. + * + * Configure via `TRUST_PROXY` (default: `1` — one hop). See `.env.example`. + */ +export function configureTrustProxy(app: Express): void { + app.set('trust proxy', config.security.trustProxy) +} + +/** + * Helmet security headers. + * + * Production uses strict defaults (CSP, HSTS, CORP/COOP). Development and + * test disable CSP/HSTS so local tooling is not blocked. + */ +export function securityHeaders() { + const isProduction = config.nodeEnv === 'production' + + return helmet({ + contentSecurityPolicy: isProduction + ? { + directives: { + defaultSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + } + : false, + crossOriginEmbedderPolicy: isProduction, + crossOriginOpenerPolicy: { policy: 'same-origin' }, + crossOriginResourcePolicy: { policy: 'same-origin' }, + hsts: isProduction + ? { + maxAge: 31_536_000, + includeSubDomains: true, + preload: true, + } + : false, + referrerPolicy: { policy: 'no-referrer' }, + }) +}