@@ -6,6 +6,7 @@ import compression from 'compression'
66import rateLimit from 'express-rate-limit'
77import { createServer } from 'http'
88import { parse } from 'url'
9+ import net from 'net'
910import { WebSocketServer } from 'ws'
1011import path from 'path'
1112import fs from 'fs'
@@ -28,11 +29,79 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
2829const app = express ( )
2930const 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 */
613679function 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
630701wss . on ( 'connection' , async ( ws , request ) => {
0 commit comments