diff --git a/core/boot/getHostVars.ts b/core/boot/getHostVars.ts index c60970ccc..2da15f48c 100644 --- a/core/boot/getHostVars.ts +++ b/core/boot/getHostVars.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import fatalError from '@lib/fatalError'; import consts from '@shared/consts'; +import { proxyIpRangeSchema } from '@lib/host/isIpInRanges'; /** @@ -39,6 +40,7 @@ export const hostEnvVarSchemas = { 'FXS_PORT cannot be between 40120 and 40150' ), INTERFACE: z.string().ip({ version: "v4" }), + PROXY_IP_RANGE: proxyIpRangeSchema, //Provider PROVIDER_NAME: z.string() diff --git a/core/boot/getNativeVars.ts b/core/boot/getNativeVars.ts index 6fca4c36a..7bf7a2302 100644 --- a/core/boot/getNativeVars.ts +++ b/core/boot/getNativeVars.ts @@ -64,6 +64,9 @@ export const getNativeVars = (ignoreDeprecatedConfigs: boolean) => { if (txAdminInterface) replacedConvarWarning('txAdminInterface', 'TXHOST_INTERFACE'); } + //Proxy IP range (new convar, no deprecation) + const txAdminProxyIpRange = getConvarString('txAdminProxyIpRange'); + if (anyWarnSent) { console.warn(`WARNING: For more information: https://aka.cfx.re/txadmin-env-config`); } @@ -81,5 +84,6 @@ export const getNativeVars = (ignoreDeprecatedConfigs: boolean) => { txDataPath, txAdminPort, txAdminInterface, + txAdminProxyIpRange, }; } diff --git a/core/globalData.ts b/core/globalData.ts index e4ae07798..ec14d2538 100644 --- a/core/globalData.ts +++ b/core/globalData.ts @@ -5,6 +5,7 @@ import slash from 'slash'; import consoleFactory, { setConsoleEnvData } from '@lib/console'; import { addLocalIpAddress } from '@lib/host/isIpAddressLocal'; +import { parseIpRanges } from '@lib/host/isIpInRanges'; import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; import { parseTxDevEnv, TxDevEnvType } from '@shared/txDevEnv'; import { Overwrite } from 'utility-types'; @@ -292,6 +293,19 @@ if (netInterface) { addLocalIpAddress(netInterface); } +//Proxy IP range +const proxyIpRangeRaw = handleMultiVar( + 'PROXY_IP_RANGE', + hostEnvVarSchemas.PROXY_IP_RANGE, + hostVars.PROXY_IP_RANGE, + undefined, + nativeVars.txAdminProxyIpRange, +); +const proxyIpRanges = proxyIpRangeRaw ? parseIpRanges(proxyIpRangeRaw) : undefined; +if (proxyIpRanges) { + console.warn('Proxy support enabled. Trusted proxy IP ranges:', proxyIpRangeRaw!.join(', ')); +} + /** * MARK: GENERAL @@ -553,6 +567,7 @@ export const txHostConfig = Object.freeze({ txaPort, fxsPort, netInterface, + proxyIpRanges, //Provider providerName, diff --git a/core/lib/host/isIpInRanges.test.ts b/core/lib/host/isIpInRanges.test.ts new file mode 100644 index 000000000..cd5c97b9c --- /dev/null +++ b/core/lib/host/isIpInRanges.test.ts @@ -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); + }); +}); diff --git a/core/lib/host/isIpInRanges.ts b/core/lib/host/isIpInRanges.ts new file mode 100644 index 000000000..62de9f830 --- /dev/null +++ b/core/lib/host/isIpInRanges.ts @@ -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; +}; + + +/** + * 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 }; +}; + +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') +); diff --git a/core/lib/host/resolveProxyRealIp.ts b/core/lib/host/resolveProxyRealIp.ts new file mode 100644 index 000000000..243198b2f --- /dev/null +++ b/core/lib/host/resolveProxyRealIp.ts @@ -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; + } + + 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; +}; diff --git a/core/modules/WebServer/index.ts b/core/modules/WebServer/index.ts index df4d1f0a1..10846a3b5 100644 --- a/core/modules/WebServer/index.ts +++ b/core/modules/WebServer/index.ts @@ -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; + if (!checkRateLimit(rateLimitIp)) return; if (req.url.startsWith('/socket.io')) { (this.io.engine as any).handleRequest(req, res); } else { diff --git a/core/modules/WebServer/middlewares/ctxVarsMw.ts b/core/modules/WebServer/middlewares/ctxVarsMw.ts index b104122ad..da22fc1eb 100644 --- a/core/modules/WebServer/middlewares/ctxVarsMw.ts +++ b/core/modules/WebServer/middlewares/ctxVarsMw.ts @@ -5,6 +5,7 @@ const console = consoleFactory(modulename); import { Next } from "koa"; import { CtxWithSession } from '../ctxTypes'; import { isIpAddressLocal } from '@lib/host/isIpAddressLocal'; +import { resolveProxyRealIp } from '@lib/host/resolveProxyRealIp'; //The custom tx-related vars set to the ctx export type CtxTxVars = { @@ -19,11 +20,17 @@ export type CtxTxVars = { * Middleware responsible for setting up the ctx.txVars */ const ctxVarsMw = (ctx: CtxWithSession, next: Next) => { + //Resolve real IP: check proxy first, then fallback to direct connection IP + const xffHeader = ctx.headers['x-forwarded-for']; + const xff = Array.isArray(xffHeader) ? xffHeader[0] : xffHeader; + const proxyResolvedIp = resolveProxyRealIp(ctx.ip, xff); + const effectiveIp = proxyResolvedIp ?? ctx.ip; + //Prepare variables const txVars: CtxTxVars = { isWebInterface: typeof ctx.headers['x-txadmin-token'] !== 'string', - realIP: ctx.ip, - isLocalRequest: isIpAddressLocal(ctx.ip), + realIP: effectiveIp, + isLocalRequest: isIpAddressLocal(effectiveIp), hostType: 'other', }; diff --git a/core/modules/WebServer/webSocket.ts b/core/modules/WebServer/webSocket.ts index 1771cf82d..68cc1b910 100644 --- a/core/modules/WebServer/webSocket.ts +++ b/core/modules/WebServer/webSocket.ts @@ -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; }; const terminateSession = (socket: SocketWithSession, reason: string, shouldLog = true) => { try {