fix(security): env-configurable trustProxy with a safe default (#61)

trustProxy was unconditionally true, so req.ip came from a client-forgeable
X-Forwarded-For and the per-IP throttles (share-AI, /mcp brute-force) were
spoofable. Make it env-configurable (TRUST_PROXY) with a safe default that
trusts XFF only from loopback/private proxies, documented in .env.example.
NOTE: this changes the default from trust-all; deployments whose proxy is on a
public IP must set TRUST_PROXY (caveat documented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 03:17:37 +03:00
parent e52f069fc6
commit 5215913533
2 changed files with 35 additions and 5 deletions

View File

@@ -3,14 +3,31 @@ APP_URL=http://localhost:3000
PORT=3000 PORT=3000
# --- Security / reverse proxy --- # --- Security / reverse proxy ---
# The app runs with Fastify `trustProxy` ENABLED, so it derives the client IP # The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
# (req.ip) from the `X-Forwarded-For` header. That header is client-forgeable. # Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
# Deploy this app behind a trusted reverse proxy that SETS/OVERWRITES (not # from proxies on the configured trusted networks. Deploy this app behind a
# appends) `X-Forwarded-For` with the real client IP. Without such a proxy, any # 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 # 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. # 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, # (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.) # 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)
# - <int> number of trusted proxy hops in front of the app
# - <list> 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 # minimum of 32 characters. Generate one with: openssl rand -hex 32
APP_SECRET=REPLACE_WITH_LONG_SECRET APP_SECRET=REPLACE_WITH_LONG_SECRET

View File

@@ -15,11 +15,24 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service'; import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers'; 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() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,
new FastifyAdapter({ new FastifyAdapter({
trustProxy: true, trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
routerOptions: { routerOptions: {
maxParamLength: 1000, maxParamLength: 1000,
ignoreTrailingSlash: true, ignoreTrailingSlash: true,