Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/boot/getHostVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


/**
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions core/boot/getNativeVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand All @@ -81,5 +84,6 @@ export const getNativeVars = (ignoreDeprecatedConfigs: boolean) => {
txDataPath,
txAdminPort,
txAdminInterface,
txAdminProxyIpRange,
};
}
15 changes: 15 additions & 0 deletions core/globalData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -553,6 +567,7 @@ export const txHostConfig = Object.freeze({
txaPort,
fxsPort,
netInterface,
proxyIpRanges,

//Provider
providerName,
Expand Down
53 changes: 53 additions & 0 deletions core/lib/host/isIpInRanges.test.ts
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);
Comment on lines +5 to +16
Copy link

Copilot AI Apr 2, 2026

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.

Copilot uses AI. Check for mistakes.
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);
});
});
64 changes: 64 additions & 0 deletions core/lib/host/isIpInRanges.ts
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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ipToNumber assumes a dotted-quad IPv4 string and does no validation (parts length, numeric octets, 0–255). For invalid inputs, the bitwise operations coerce NaN to 0, which can cause incorrect range matches and undermines the trusted-proxy security boundary. Make this parsing reject/return a sentinel for invalid IPs and have isIpInRanges return false for invalid inputs.

Copilot uses AI. Check for mistakes.


/**
* 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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseCidr doesn’t validate the prefix length; values >32 or NaN will produce incorrect masks due to JS shift semantics. Since these ranges gate trusted-proxy behavior, validate that the prefix is an integer in [0, 32] (and that the IP portion is valid) before computing the mask/network.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proxyIpRangeSchema currently accepts values like 999.999.999.999/99 (octets and prefix aren’t bounded). Those will be parsed into unintended networks/masks and could silently expand the trusted-proxy set. Tighten validation (e.g. zod IPv4 validation + explicit prefix 0–32) so invalid configs fail fast with a clear message.

Copilot uses AI. Check for mistakes.
);
35 changes: 35 additions & 0 deletions core/lib/host/resolveProxyRealIp.ts
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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

socketIp may not be a valid IPv4 string (e.g. 'unknown', IPv6, or IPv4-mapped IPv6 like ::ffff:10.0.0.1). Because isIpInRanges currently coerces invalid input into a number, a non-IP can be treated as 0.0.0.0 and accidentally match trusted ranges (especially 0.0.0.0/0), enabling client-IP spoofing via X-Forwarded-For. Add strict validation/normalization of socketIp before isIpInRanges (and ideally inside isIpInRanges as well).

Copilot uses AI. Check for mistakes.

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;
};
6 changes: 5 additions & 1 deletion core/modules/WebServer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveProxyRealIp is typed to accept (socketIp: string, xffHeader: string | undefined), but here socketIp can be undefined and req.headers['x-forwarded-for'] can be string | string[] | undefined in Node. With strict: true this should fail type-checking; also it’s inconsistent with the other call sites that normalize XFF. Consider normalizing XFF to a single string and only calling the resolver when socketIp is a string (or widen the resolver signature to accept string | undefined / string | string[] | undefined).

Suggested change
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;

Copilot uses AI. Check for mistakes.
if (!checkRateLimit(rateLimitIp)) return;
if (req.url.startsWith('/socket.io')) {
(this.io.engine as any).handleRequest(req, res);
} else {
Expand Down
11 changes: 9 additions & 2 deletions core/modules/WebServer/middlewares/ctxVarsMw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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',
};

Expand Down
6 changes: 5 additions & 1 deletion core/modules/WebServer/webSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getIP can produce 'unknown' when remoteAddress is missing, but that value is passed into resolveProxyRealIp. With the current isIpInRanges implementation, invalid IP strings can be coerced to 0.0.0.0 and mistakenly considered trusted, allowing X-Forwarded-For spoofing. Avoid calling the proxy resolver unless socketIp is a validated/normalized IP (or pass undefined when unavailable).

Copilot uses AI. Check for mistakes.
};
const terminateSession = (socket: SocketWithSession, reason: string, shouldLog = true) => {
try {
Expand Down
Loading