-
Notifications
You must be signed in to change notification settings - Fork 674
feat: add reverse proxy support via trusted IP range config #1105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { suite, it, expect } from 'vitest'; | ||
| import { parseIpRanges, isIpInRanges } from './isIpInRanges'; | ||
|
|
||
|
|
||
| suite('parseIpRanges + isIpInRanges', () => { | ||
| it('should match a single IP (/32)', () => { | ||
| const ranges = parseIpRanges(['10.0.0.1']); | ||
| expect(isIpInRanges('10.0.0.1', ranges)).toBe(true); | ||
| expect(isIpInRanges('10.0.0.2', ranges)).toBe(false); | ||
| }); | ||
|
|
||
| it('should match a /24 range', () => { | ||
| const ranges = parseIpRanges(['192.168.1.0/24']); | ||
| expect(isIpInRanges('192.168.1.0', ranges)).toBe(true); | ||
| expect(isIpInRanges('192.168.1.255', ranges)).toBe(true); | ||
| expect(isIpInRanges('192.168.1.100', ranges)).toBe(true); | ||
| expect(isIpInRanges('192.168.2.1', ranges)).toBe(false); | ||
| }); | ||
|
|
||
| it('should match a /16 range', () => { | ||
| const ranges = parseIpRanges(['172.16.0.0/16']); | ||
| expect(isIpInRanges('172.16.0.1', ranges)).toBe(true); | ||
| expect(isIpInRanges('172.16.255.255', ranges)).toBe(true); | ||
| expect(isIpInRanges('172.17.0.1', ranges)).toBe(false); | ||
| }); | ||
|
|
||
| it('should match a /8 range', () => { | ||
| const ranges = parseIpRanges(['10.0.0.0/8']); | ||
| expect(isIpInRanges('10.0.0.1', ranges)).toBe(true); | ||
| expect(isIpInRanges('10.255.255.255', ranges)).toBe(true); | ||
| expect(isIpInRanges('11.0.0.1', ranges)).toBe(false); | ||
| }); | ||
|
|
||
| it('should handle multiple ranges', () => { | ||
| const ranges = parseIpRanges(['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']); | ||
| expect(isIpInRanges('10.1.2.3', ranges)).toBe(true); | ||
| expect(isIpInRanges('172.20.5.5', ranges)).toBe(true); | ||
| expect(isIpInRanges('192.168.100.1', ranges)).toBe(true); | ||
| expect(isIpInRanges('8.8.8.8', ranges)).toBe(false); | ||
| }); | ||
|
|
||
| it('should handle /0 (match everything)', () => { | ||
| const ranges = parseIpRanges(['0.0.0.0/0']); | ||
| expect(isIpInRanges('1.2.3.4', ranges)).toBe(true); | ||
| expect(isIpInRanges('255.255.255.255', ranges)).toBe(true); | ||
| }); | ||
|
|
||
| it('should handle /32 explicit', () => { | ||
| const ranges = parseIpRanges(['1.2.3.4/32']); | ||
| expect(isIpInRanges('1.2.3.4', ranges)).toBe(true); | ||
| expect(isIpInRanges('1.2.3.5', ranges)).toBe(false); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
|
|
||
| /** | ||
| * Parses an IPv4 address string into a 32-bit number. | ||
| */ | ||
| const ipToNumber = (ip: string) => { | ||
| const parts = ip.split('.'); | ||
| return ( | ||
| (parseInt(parts[0]) << 24) | ||
| | (parseInt(parts[1]) << 16) | ||
| | (parseInt(parts[2]) << 8) | ||
| | parseInt(parts[3]) | ||
| ) >>> 0; | ||
| }; | ||
|
Comment on lines
+7
to
+15
|
||
|
|
||
|
|
||
| /** | ||
| * Parses a CIDR notation string (e.g. "10.0.0.0/8") or a single IP into | ||
| * a { network, mask } pair for fast matching. | ||
| */ | ||
| const parseCidr = (cidr: string) => { | ||
| const [ip, prefixStr] = cidr.split('/'); | ||
| const prefix = prefixStr !== undefined ? parseInt(prefixStr) : 32; | ||
| const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; | ||
| const network = ipToNumber(ip) & mask; | ||
| return { network, mask }; | ||
| }; | ||
|
Comment on lines
+22
to
+28
|
||
|
|
||
| type ParsedRange = { network: number; mask: number }; | ||
|
|
||
|
|
||
| /** | ||
| * Pre-parses an array of CIDR strings into a reusable matcher. | ||
| * Accepts both "1.2.3.4/24" and plain "1.2.3.4" (treated as /32). | ||
| */ | ||
| export const parseIpRanges = (ranges: string[]): ParsedRange[] => { | ||
| return ranges.map(parseCidr); | ||
| }; | ||
|
|
||
|
|
||
| /** | ||
| * Returns true if the given IPv4 address falls within any of the pre-parsed ranges. | ||
| */ | ||
| export const isIpInRanges = (ip: string, ranges: ParsedRange[]) => { | ||
| const num = ipToNumber(ip); | ||
| for (const range of ranges) { | ||
| if ((num & range.mask) === range.network) return true; | ||
| } | ||
| return false; | ||
| }; | ||
|
|
||
|
|
||
| /** | ||
| * Zod schema for a comma-separated list of CIDR ranges or IPs. | ||
| * Example: "10.0.0.0/8,172.16.0.0/12,192.168.1.1" | ||
| */ | ||
| const cidrRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?$/; | ||
| export const proxyIpRangeSchema = z.string().transform((val: string) => { | ||
| return val.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0); | ||
| }).pipe( | ||
| z.array(z.string().regex(cidrRegex, 'Each entry must be a valid IPv4 address or CIDR range (e.g. 10.0.0.0/8)')) | ||
| .min(1, 'At least one IP or CIDR range is required') | ||
|
Comment on lines
+58
to
+63
|
||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { txHostConfig } from '@core/globalData'; | ||
| import { isIpInRanges } from '@lib/host/isIpInRanges'; | ||
| import consts from '@shared/consts'; | ||
|
|
||
|
|
||
| /** | ||
| * Resolves the real client IP from X-Forwarded-For header when the | ||
| * direct connection IP is a trusted proxy. Returns the original IP | ||
| * if proxy support is not configured or the source is not trusted. | ||
| * | ||
| * When there are multiple proxies, we walk X-Forwarded-For from right | ||
| * to left, skipping trusted proxy IPs, and return the first untrusted one. | ||
| */ | ||
| export const resolveProxyRealIp = (socketIp: string, xffHeader: string | undefined) => { | ||
| const { proxyIpRanges } = txHostConfig; | ||
| if (!proxyIpRanges || !isIpInRanges(socketIp, proxyIpRanges)) { | ||
| return undefined; | ||
| } | ||
|
Comment on lines
+14
to
+18
|
||
|
|
||
| if (!xffHeader) return undefined; | ||
|
|
||
| // X-Forwarded-For: client, proxy1, proxy2 | ||
| // Walk from right to left, skipping trusted proxies | ||
| const ips = xffHeader.split(',').map((s) => s.trim()); | ||
| for (let i = ips.length - 1; i >= 0; i--) { | ||
| const ip = ips[i]; | ||
| if (!consts.regexValidIP.test(ip)) continue; | ||
| if (!isIpInRanges(ip, proxyIpRanges)) { | ||
| return ip; | ||
| } | ||
| } | ||
|
|
||
| // All IPs in the chain are trusted proxies — return the leftmost | ||
| return ips[0] && consts.regexValidIP.test(ips[0]) ? ips[0] : undefined; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,6 +27,7 @@ import fatalError from '@lib/fatalError'; | |||||||||||||||||||||||
| import { isProxy } from 'node:util/types'; | ||||||||||||||||||||||||
| import serveStaticMw from './middlewares/serveStaticMw'; | ||||||||||||||||||||||||
| import serveRuntimeMw from './middlewares/serveRuntimeMw'; | ||||||||||||||||||||||||
| import { resolveProxyRealIp } from '@lib/host/resolveProxyRealIp'; | ||||||||||||||||||||||||
| const console = consoleFactory(modulename); | ||||||||||||||||||||||||
| const nanoid = customAlphabet(dict49, 32); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -175,7 +176,10 @@ export default class WebServer { | |||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| // console.debug(`HTTP ${req.method} ${req.url}`); | ||||||||||||||||||||||||
| if (!checkHttpLoad()) return; | ||||||||||||||||||||||||
| if (!checkRateLimit(req?.socket?.remoteAddress)) return; | ||||||||||||||||||||||||
| const socketIp = req?.socket?.remoteAddress; | ||||||||||||||||||||||||
| const xff = req?.headers?.['x-forwarded-for']; | ||||||||||||||||||||||||
| const rateLimitIp = resolveProxyRealIp(socketIp, xff) ?? socketIp; | ||||||||||||||||||||||||
|
Comment on lines
+180
to
+181
|
||||||||||||||||||||||||
| const xff = req?.headers?.['x-forwarded-for']; | |
| const rateLimitIp = resolveProxyRealIp(socketIp, xff) ?? socketIp; | |
| const rawXff = req?.headers?.['x-forwarded-for']; | |
| const xff = typeof rawXff === 'string' | |
| ? rawXff | |
| : Array.isArray(rawXff) | |
| ? rawXff.join(', ') | |
| : undefined; | |
| const rateLimitIp = typeof socketIp === 'string' | |
| ? resolveProxyRealIp(socketIp, xff) ?? socketIp | |
| : socketIp; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import serverlogRoom from './wsRooms/serverlog'; | |
| import { AuthedAdminType, checkRequestAuth } from './authLogic'; | ||
| import { SocketWithSession } from './ctxTypes'; | ||
| import { isIpAddressLocal } from '@lib/host/isIpAddressLocal'; | ||
| import { resolveProxyRealIp } from '@lib/host/resolveProxyRealIp'; | ||
| import { txEnv } from '@core/globalData'; | ||
| const console = consoleFactory(modulename); | ||
|
|
||
|
|
@@ -34,7 +35,10 @@ type RoomNames = typeof VALID_ROOMS[number]; | |
|
|
||
| //Helpers | ||
| const getIP = (socket: SocketWithSession) => { | ||
| return socket?.request?.socket?.remoteAddress ?? 'unknown'; | ||
| const socketIp = socket?.request?.socket?.remoteAddress ?? 'unknown'; | ||
| const xff = socket?.request?.headers?.['x-forwarded-for']; | ||
| const xffStr = Array.isArray(xff) ? xff[0] : xff; | ||
| return resolveProxyRealIp(socketIp, xffStr) ?? socketIp; | ||
|
Comment on lines
37
to
+41
|
||
| }; | ||
| const terminateSession = (socket: SocketWithSession, reason: string, shouldLog = true) => { | ||
| try { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new CIDR matcher is security-sensitive (trusted proxy boundary) but the tests only cover happy-path ranges. Add coverage for invalid inputs (bad octets, prefix >32/negative, empty segments) and for IPv4-mapped IPv6 strings (e.g.
::ffff:10.0.0.1) to ensure the matcher/resolver fails closed instead of matching unexpectedly.