🚦 fix: ERR_ERL_INVALID_IP_ADDRESS and IPv6 Key Collisions in IP Rate Limiters (#12319)

* fix: Add removePorts keyGenerator to all IP-based rate limiters

Six IP-based rate limiters are missing the `keyGenerator: removePorts`
option that is already used by the auth-related limiters (login,
register, resetPassword, verifyEmail). Without it, reverse proxies that
include ports in X-Forwarded-For headers cause
ERR_ERL_INVALID_IP_ADDRESS errors from express-rate-limit.

Fixes #12318

* fix: make removePorts IPv6-safe to prevent rate-limit key collisions

The original regex `/:\d+[^:]*$/` treated the last colon-delimited
segment of bare IPv6 addresses as a port, mangling valid IPs
(e.g. `::1` → `::`, `2001:db8::1` → `2001:db8::`). Distinct IPv6
clients could collapse into the same rate-limit bucket.

Use `net.isIP()` as a fast path for already-valid IPs, then match
bracketed IPv6+port and IPv4+port explicitly. Bare IPv6 addresses
are now returned unchanged.

Also fixes pre-existing property ordering inconsistency in
ttsLimiters.js userLimiterOptions (keyGenerator before store).

* refactor: move removePorts to packages/api as TypeScript, fix import order

- Move removePorts implementation to packages/api/src/utils/removePorts.ts
  with proper Express Request typing
- Reduce api/server/utils/removePorts.js to a thin re-export from
  @librechat/api for backward compatibility
- Consolidate removePorts import with limiterCache from @librechat/api
  in all 6 limiter files, fixing import order (package imports shortest
  to longest, local imports longest to shortest)
- Remove narrating inline comments per code style guidelines

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Brad Russell 2026-03-19 21:48:03 -04:00 committed by GitHub
parent 748fd086c1
commit ecd6d76bc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 162 additions and 28 deletions

View file

@ -18,6 +18,7 @@ export * from './math';
export * from './oidc';
export * from './openid';
export * from './promise';
export * from './ports';
export * from './sanitizeTitle';
export * from './tempChatRetention';
export * from './text';

View file

@ -0,0 +1,98 @@
import type { Request } from 'express';
import { removePorts } from './ports';
const req = (ip: string | undefined): Request => ({ ip }) as Request;
describe('removePorts', () => {
describe('bare IPv4 (no port)', () => {
test('returns a standard private IP unchanged', () => {
expect(removePorts(req('192.168.1.1'))).toBe('192.168.1.1');
});
test('returns a public IP unchanged', () => {
expect(removePorts(req('149.154.20.46'))).toBe('149.154.20.46');
});
test('returns loopback unchanged', () => {
expect(removePorts(req('127.0.0.1'))).toBe('127.0.0.1');
});
});
describe('IPv4 with port (the primary bug scenario)', () => {
test('strips port from a private IP', () => {
expect(removePorts(req('192.168.1.1:8080'))).toBe('192.168.1.1');
});
test('strips port from the IP in the original issue report', () => {
expect(removePorts(req('149.154.20.46:48198'))).toBe('149.154.20.46');
});
test('strips a low port number', () => {
expect(removePorts(req('10.0.0.1:80'))).toBe('10.0.0.1');
});
test('strips a high port number', () => {
expect(removePorts(req('10.0.0.1:65535'))).toBe('10.0.0.1');
});
});
describe('bare IPv6 (no port)', () => {
test('returns loopback unchanged', () => {
expect(removePorts(req('::1'))).toBe('::1');
});
test('returns a full address unchanged', () => {
expect(removePorts(req('2001:db8::1'))).toBe('2001:db8::1');
});
test('returns an IPv4-mapped IPv6 address unchanged', () => {
expect(removePorts(req('::ffff:192.168.1.1'))).toBe('::ffff:192.168.1.1');
});
test('returns a fully expanded IPv6 unchanged', () => {
expect(removePorts(req('2001:0db8:85a3:0000:0000:8a2e:0370:7334'))).toBe(
'2001:0db8:85a3:0000:0000:8a2e:0370:7334',
);
});
});
describe('bracketed IPv6 with port', () => {
test('extracts loopback from brackets with port', () => {
expect(removePorts(req('[::1]:8080'))).toBe('::1');
});
test('extracts a full address from brackets with port', () => {
expect(removePorts(req('[2001:db8::1]:443'))).toBe('2001:db8::1');
});
test('extracts address from brackets without port', () => {
expect(removePorts(req('[::1]'))).toBe('::1');
});
});
describe('falsy / missing ip', () => {
test('returns undefined when ip is undefined', () => {
expect(removePorts(req(undefined))).toBeUndefined();
});
test('returns undefined when ip is empty string', () => {
expect(removePorts({ ip: '' } as Request)).toBe('');
});
test('returns undefined when req is null', () => {
expect(removePorts(null as unknown as Request)).toBeUndefined();
});
});
describe('IPv4-mapped IPv6 with port', () => {
test('strips port from an IPv4-mapped IPv6 address', () => {
expect(removePorts(req('::ffff:1.2.3.4:8080'))).toBe('::ffff:1.2.3.4');
});
});
describe('unrecognized formats fall through unchanged', () => {
test('returns garbage input unchanged', () => {
expect(removePorts(req('not-an-ip'))).toBe('not-an-ip');
});
});
});

View file

@ -0,0 +1,38 @@
import type { Request } from 'express';
/** Strips port suffix from req.ip for use as a rate-limiter key (IPv4 and IPv6-safe) */
export function removePorts(req: Request): string | undefined {
const ip = req?.ip;
if (!ip) {
return ip;
}
if (ip.charCodeAt(0) === 91) {
const close = ip.indexOf(']');
return close > 0 ? ip.slice(1, close) : ip;
}
const lastColon = ip.lastIndexOf(':');
if (lastColon === -1) {
return ip;
}
if (ip.indexOf('.') !== -1 && hasOnlyDigitsAfter(ip, lastColon + 1)) {
return ip.slice(0, lastColon);
}
return ip;
}
function hasOnlyDigitsAfter(str: string, start: number): boolean {
if (start >= str.length) {
return false;
}
for (let i = start; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c < 48 || c > 57) {
return false;
}
}
return true;
}