Skip to content

Commit ee6a374

Browse files
authored
fix(server): restrict trusted proxy headers (#16)
1 parent 507042f commit ee6a374

2 files changed

Lines changed: 97 additions & 20 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ WEBSOCKET_MAX_CONNECTIONS_PER_IP=5
2020
# Public origin allowed by the API CORS allowlist (server-side). Defaults to
2121
# https://explorer.fairco.in when unset.
2222
PUBLIC_BASE_URL=https://explorer.fairco.in
23+
24+
# Reverse proxies whose X-Forwarded-* headers are trusted for client IP rate
25+
# limiting. Leave blank if the API can be reached directly; set comma-separated
26+
# IP/CIDR ranges (for example: 127.0.0.1,10.0.0.0/8,::1) only for proxies that
27+
# overwrite or sanitize X-Forwarded-For before forwarding requests.
28+
TRUSTED_PROXY_CIDRS=
2329
HOST=0.0.0.0
2430
PORT=8080
2531

server/index.ts

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import compression from 'compression'
66
import rateLimit from 'express-rate-limit'
77
import { createServer } from 'http'
88
import { parse } from 'url'
9+
import net from 'net'
910
import { WebSocketServer } from 'ws'
1011
import path from 'path'
1112
import fs from 'fs'
@@ -28,11 +29,79 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
2829
const app = express()
2930
const PORT = parseInt(process.env.PORT || '8080', 10)
3031

31-
// Behind one reverse proxy (e.g. nginx/DO app platform): trust a single hop so
32-
// `req.ip` and the rate limiter use the real client IP, not a spoofable header.
33-
// The same hop count is used to resolve the WebSocket client IP (see resolveClientIp).
34-
const TRUST_PROXY_HOPS = 1
35-
app.set('trust proxy', TRUST_PROXY_HOPS)
32+
// Trust X-Forwarded-* headers only when the direct TCP peer is an explicitly
33+
// configured reverse proxy. Leave TRUSTED_PROXY_CIDRS empty when the backend can
34+
// be reached directly; set it to comma-separated proxy IP/CIDR ranges (for
35+
// example: "127.0.0.1,10.0.0.0/8,::1") when every request arrives through those
36+
// proxies. Express and the WebSocket path both use this same trust decision.
37+
const TRUSTED_PROXY_CIDRS = (process.env.TRUSTED_PROXY_CIDRS || '')
38+
.split(',')
39+
.map(value => value.trim())
40+
.filter(Boolean)
41+
42+
interface TrustedProxyRange {
43+
network: bigint
44+
mask: bigint
45+
family: 4 | 6
46+
}
47+
48+
function normalizeIp(ip: string | undefined): string | undefined {
49+
if (!ip) return undefined
50+
if (ip.startsWith('::ffff:') && net.isIP(ip.slice(7)) === 4) {
51+
return ip.slice(7)
52+
}
53+
return ip
54+
}
55+
56+
function ipToBigInt(ip: string): bigint | null {
57+
const normalized = normalizeIp(ip)
58+
if (!normalized) return null
59+
const family = net.isIP(normalized)
60+
if (family === 4) {
61+
return normalized.split('.').reduce((acc, part) => (acc << 8n) + BigInt(Number(part)), 0n)
62+
}
63+
if (family === 6) {
64+
const [headRaw, tailRaw] = normalized.split('::')
65+
const head = headRaw ? headRaw.split(':').filter(Boolean) : []
66+
const tail = tailRaw ? tailRaw.split(':').filter(Boolean) : []
67+
const fill = new Array(8 - head.length - tail.length).fill('0')
68+
const parts = [...head, ...fill, ...tail]
69+
if (parts.length !== 8) return null
70+
return parts.reduce((acc, part) => (acc << 16n) + BigInt(parseInt(part || '0', 16)), 0n)
71+
}
72+
return null
73+
}
74+
75+
function parseTrustedProxy(value: string): TrustedProxyRange | null {
76+
const [rawIp, rawPrefix] = value.split('/')
77+
const ip = normalizeIp(rawIp)
78+
if (!ip) return null
79+
const family = net.isIP(ip)
80+
if (family !== 4 && family !== 6) return null
81+
const bits = family === 4 ? 32 : 128
82+
const prefix = rawPrefix === undefined ? bits : Number(rawPrefix)
83+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) return null
84+
const ipValue = ipToBigInt(ip)
85+
if (ipValue === null) return null
86+
const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix)
87+
return { network: ipValue & mask, mask, family }
88+
}
89+
90+
const TRUSTED_PROXY_RANGES = TRUSTED_PROXY_CIDRS
91+
.map(parseTrustedProxy)
92+
.filter((range): range is TrustedProxyRange => Boolean(range))
93+
94+
function isTrustedProxy(ip: string | undefined): boolean {
95+
const normalized = normalizeIp(ip)
96+
if (!normalized) return false
97+
const family = net.isIP(normalized)
98+
if (family !== 4 && family !== 6) return false
99+
const value = ipToBigInt(normalized)
100+
if (value === null) return false
101+
return TRUSTED_PROXY_RANGES.some(range => range.family === family && (value & range.mask) === range.network)
102+
}
103+
104+
app.set('trust proxy', isTrustedProxy)
36105

37106
// ---- Security & performance middleware ----
38107

@@ -602,29 +671,31 @@ function loadWsHandler(): Promise<WebSocketHandlerModule | null> {
602671
}
603672

604673
/**
605-
* Resolve the client IP for the per-IP WebSocket connection cap.
606-
*
607-
* A raw client can put any value in `X-Forwarded-For`, so trusting its left-most
608-
* entry lets attackers bypass the per-IP limit. With one trusted proxy
609-
* (`trust proxy = 1`), the authoritative client address is the entry our proxy
610-
* appended — the right-most XFF value — falling back to the real TCP peer
611-
* address (`socket.remoteAddress`) when there is no proxy header.
674+
* Resolve the client IP for the per-IP WebSocket connection cap using the same
675+
* trusted-proxy policy as Express. Direct clients and untrusted peers cannot
676+
* spoof their identity with X-Forwarded-For; only configured proxy peers can
677+
* contribute forwarded addresses.
612678
*/
613679
function resolveClientIp(request: { headers: NodeJS.Dict<string | string[]>; socket: { remoteAddress?: string } }): string | undefined {
614-
const socketAddress = request.socket.remoteAddress
615-
if (!TRUST_PROXY_HOPS) {
680+
const socketAddress = normalizeIp(request.socket.remoteAddress)
681+
if (!isTrustedProxy(socketAddress)) {
616682
return socketAddress
617683
}
684+
618685
const forwarded = request.headers['x-forwarded-for']
619686
const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded
620-
if (raw) {
621-
const parts = raw.split(',').map(part => part.trim()).filter(Boolean)
622-
const trusted = parts[parts.length - 1]
623-
if (trusted) {
624-
return trusted
687+
const parts = raw?.split(',').map(part => normalizeIp(part.trim())).filter(Boolean) as string[] | undefined
688+
if (!parts?.length) {
689+
return socketAddress
690+
}
691+
692+
for (let index = parts.length - 1; index >= 0; index -= 1) {
693+
const candidate = parts[index]
694+
if (!isTrustedProxy(candidate)) {
695+
return candidate
625696
}
626697
}
627-
return socketAddress
698+
return parts[0]
628699
}
629700

630701
wss.on('connection', async (ws, request) => {

0 commit comments

Comments
 (0)