diff --git a/.env.example b/.env.example index a19fd2d7..4cebe788 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,31 @@ APP_URL=http://localhost:3000 PORT=3000 # --- Security / reverse proxy --- -# The app runs with Fastify `trustProxy` ENABLED, so it derives the client IP -# (req.ip) from the `X-Forwarded-For` header. That header is client-forgeable. -# Deploy this app behind a trusted reverse proxy that SETS/OVERWRITES (not -# appends) `X-Forwarded-For` with the real client IP. Without such a proxy, any +# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via +# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only +# from proxies on the configured trusted networks. Deploy this app behind a +# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For` +# with the real client IP. If XFF is trusted from an untrusted source, any # per-IP throttling — including the /mcp Basic brute-force limiter — can be # bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs. # (The /mcp limiter keeps a global per-email key as an IP-independent backstop, # but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.) +# +# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For. +# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted +# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip. +# This is the safe default for the common case where the reverse proxy runs on +# loopback or a private network; req.ip still resolves to the real client. +# WARNING: this changed the previous default of trust-all. If your reverse proxy +# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the +# proxy's IP — set TRUST_PROXY accordingly. Accepted values: +# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites +# X-Forwarded-For; otherwise clients can spoof their IP) +# - false never trust X-Forwarded-For (req.ip is the socket peer) +# - number of trusted proxy hops in front of the app +# - comma-separated CIDR/IP list of trusted proxies, e.g. +# `127.0.0.1, 10.0.0.0/8` +# TRUST_PROXY= # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1c2ccebf..3588f045 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -15,11 +15,24 @@ import { InternalLogFilter } from './common/logger/internal-log-filter'; import { EnvironmentService } from './integrations/environment/environment.service'; import { resolveFrameHeader } from './common/helpers'; +// Trust X-Forwarded-For ONLY from real proxies on private/loopback nets by +// default, so a public-IP client cannot spoof its IP via X-Forwarded-For. +// TRUST_PROXY env overrides: 'true'/'false', a hop count (integer), or a +// CIDR/IP list string passed through to Fastify/proxy-addr. +function resolveTrustProxy(rawInput?: string): boolean | number | string { + const raw = rawInput?.trim(); + if (raw == null || raw === '') return 'loopback, linklocal, uniquelocal'; + if (raw === 'true') return true; + if (raw === 'false') return false; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : raw; +} + async function bootstrap() { const app = await NestFactory.create( AppModule, new FastifyAdapter({ - trustProxy: true, + trustProxy: resolveTrustProxy(process.env.TRUST_PROXY), routerOptions: { maxParamLength: 1000, ignoreTrailingSlash: true,