|
| 1 | +import { isIP } from "node:net"; |
1 | 2 | import type { |
2 | 3 | FastifyBaseLogger, |
3 | 4 | FastifyError, |
@@ -102,28 +103,43 @@ export function registerControlledRequestLogging( |
102 | 103 | }); |
103 | 104 | } |
104 | 105 |
|
105 | | -// Trust proxy hops in front of the API. The default deployment terminates TLS |
106 | | -// at Traefik (one hop) and forwards to this container, so `request.ip` must be |
107 | | -// derived from the leftmost untrusted X-Forwarded-For entry rather than from |
108 | | -// the socket peer (which would always be the proxy). Without this, IP-keyed |
109 | | -// rate limits collapse to a single bucket per upstream proxy. |
110 | | -// |
111 | | -// SECURITY: never set this to `true` (trust everyone) on a public deployment |
112 | | -// — that would let any client spoof their IP via X-Forwarded-For and bypass |
113 | | -// rate limits, audit attribution, and the loopback admin short-circuit. Set |
114 | | -// `TRUST_PROXY_HOPS` to the *exact* number of proxies between the public |
115 | | -// internet and this process (default 1 = one Traefik hop). Set it to `0` for |
116 | | -// direct exposure (development). |
117 | | -export function trustProxyConfig(): number | boolean { |
118 | | - const raw = process.env.TRUST_PROXY_HOPS?.trim(); |
119 | | - if (raw === undefined || raw === "") return 1; // default: assume one Traefik hop |
120 | | - const n = Number(raw); |
121 | | - if (!Number.isInteger(n) || n < 0) { |
122 | | - throw new Error( |
123 | | - `TRUST_PROXY_HOPS must be a non-negative integer (got "${raw}"). Use 0 for direct exposure, 1 for a single reverse proxy (default).`, |
124 | | - ); |
| 106 | +const TRUST_PROXY_ALIASES = new Set(["loopback", "linklocal", "uniquelocal"]); |
| 107 | + |
| 108 | +function validateProxyRange(value: string): void { |
| 109 | + if (TRUST_PROXY_ALIASES.has(value)) return; |
| 110 | + const parts = value.split("/"); |
| 111 | + const address = parts[0] ?? ""; |
| 112 | + const family = isIP(address); |
| 113 | + if (family === 0 || parts.length > 2) { |
| 114 | + throw new Error(`TRUST_PROXY_RANGES contains an invalid IP or CIDR: "${value}"`); |
| 115 | + } |
| 116 | + if (parts.length === 1) return; |
| 117 | + const prefixText = parts[1] ?? ""; |
| 118 | + const prefix = Number(prefixText); |
| 119 | + const maximum = family === 4 ? 32 : 128; |
| 120 | + if (!/^\d+$/.test(prefixText) || !Number.isInteger(prefix) || prefix > maximum) { |
| 121 | + throw new Error(`TRUST_PROXY_RANGES contains an invalid CIDR prefix: "${value}"`); |
| 122 | + } |
| 123 | + if (prefix === 0) { |
| 124 | + throw new Error(`TRUST_PROXY_RANGES must not trust every address: "${value}"`); |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +// Forwarding headers are ignored unless the immediate socket peer belongs to |
| 129 | +// an explicitly trusted IP/CIDR range. The default container deployment sets |
| 130 | +// `uniquelocal`, which covers Docker's private IPv4 and ULA IPv6 networks; a |
| 131 | +// directly run development server leaves the variable unset and trusts none. |
| 132 | +// Address-based trust is essential: hop counts cannot distinguish the real |
| 133 | +// reverse proxy from a client that reaches the origin directly. |
| 134 | +export function trustProxyConfig(): false | string[] { |
| 135 | + const raw = process.env.TRUST_PROXY_RANGES?.trim(); |
| 136 | + if (raw === undefined || raw === "") return false; |
| 137 | + const ranges = raw.split(",").map((value) => value.trim()); |
| 138 | + if (ranges.some((value) => value.length === 0)) { |
| 139 | + throw new Error("TRUST_PROXY_RANGES must be a comma-separated list without empty entries"); |
125 | 140 | } |
126 | | - return n; |
| 141 | + for (const range of ranges) validateProxyRange(range); |
| 142 | + return ranges; |
127 | 143 | } |
128 | 144 |
|
129 | 145 | // Uniform error body. Throwing handlers/guards (requireAuth/requireAdmin throw |
|
0 commit comments