Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
* <number> → 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.
*/
Expand Down Expand Up @@ -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).
Expand Down
12 changes: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { logger } from "../utils/logger";
export { errorHandler } from "./errorHandler";
export { rateLimiter } from "./rateLimiter";
export { rateLimiter } from "./rateLimiter";
export { configureTrustProxy, securityHeaders } from "./security";
31 changes: 30 additions & 1 deletion src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────

/**
Expand All @@ -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.',
},
Expand Down Expand Up @@ -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).
Expand Down
45 changes: 45 additions & 0 deletions src/middleware/security.ts
Original file line number Diff line number Diff line change
@@ -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' },
})
}
Loading