diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js index 4d321889c1..36945ca420 100644 --- a/api/cache/banViolation.js +++ b/api/cache/banViolation.js @@ -1,8 +1,7 @@ const { logger } = require('@librechat/data-schemas'); -const { isEnabled, math } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { isEnabled, math, removePorts } = require('@librechat/api'); const { deleteAllUserSessions } = require('~/models'); -const { removePorts } = require('~/server/utils'); const getLogStores = require('./getLogStores'); const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {}; diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index 79804a84e1..0c98f3a824 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,11 +1,10 @@ const { Keyv } = require('keyv'); const uap = require('ua-parser-js'); const { logger } = require('@librechat/data-schemas'); -const { isEnabled, keyvMongo } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts } = require('~/server/utils'); -const denyRequest = require('./denyRequest'); +const { isEnabled, keyvMongo, removePorts } = require('@librechat/api'); const { getLogStores } = require('~/cache'); +const denyRequest = require('./denyRequest'); const { findUser } = require('~/models'); const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 }); diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js index f1e9b15f11..6d05cedad5 100644 --- a/api/server/middleware/limiters/forkLimiters.js +++ b/api/server/middleware/limiters/forkLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -59,6 +59,7 @@ const createForkLimiters = () => { windowMs: forkIpWindowMs, max: forkIpMax, handler: createForkHandler(), + keyGenerator: removePorts, store: limiterCache('fork_ip_limiter'), }; const userLimiterOptions = { diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js index f383e99563..22b7013558 100644 --- a/api/server/middleware/limiters/importLimiters.js +++ b/api/server/middleware/limiters/importLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -60,6 +60,7 @@ const createImportLimiters = () => { windowMs: importIpWindowMs, max: importIpMax, handler: createImportHandler(), + keyGenerator: removePorts, store: limiterCache('import_ip_limiter'), }; const userLimiterOptions = { @@ -67,7 +68,7 @@ const createImportLimiters = () => { max: importUserMax, handler: createImportHandler(false), keyGenerator: function (req) { - return req.user?.id; // Use the user ID or NULL if not available + return req.user?.id; }, store: limiterCache('import_user_limiter'), }; diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js index eef0c56bfc..c178b68a25 100644 --- a/api/server/middleware/limiters/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,7 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts } = require('~/server/utils'); +const { limiterCache, removePorts } = require('@librechat/api'); const { logViolation } = require('~/cache'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js index 50f4dbc644..4f1d72076f 100644 --- a/api/server/middleware/limiters/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const denyRequest = require('~/server/middleware/denyRequest'); const { logViolation } = require('~/cache'); @@ -50,6 +50,7 @@ const ipLimiterOptions = { windowMs: ipWindowMs, max: ipMax, handler: createHandler(), + keyGenerator: removePorts, store: limiterCache('message_ip_limiter'), }; @@ -58,7 +59,7 @@ const userLimiterOptions = { max: userMax, handler: createHandler(false), keyGenerator: function (req) { - return req.user?.id; // Use the user ID or NULL if not available + return req.user?.id; }, store: limiterCache('message_user_limiter'), }; diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js index eeebebdb42..91ea027376 100644 --- a/api/server/middleware/limiters/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,7 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts } = require('~/server/utils'); +const { limiterCache, removePorts } = require('@librechat/api'); const { logViolation } = require('~/cache'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js index d1dfe52a98..7feca47ca5 100644 --- a/api/server/middleware/limiters/resetPasswordLimiter.js +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -1,7 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts } = require('~/server/utils'); +const { limiterCache, removePorts } = require('@librechat/api'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js index f2f47cf680..ded9040033 100644 --- a/api/server/middleware/limiters/sttLimiters.js +++ b/api/server/middleware/limiters/sttLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -54,6 +54,7 @@ const createSTTLimiters = () => { windowMs: sttIpWindowMs, max: sttIpMax, handler: createSTTHandler(), + keyGenerator: removePorts, store: limiterCache('stt_ip_limiter'), }; @@ -62,7 +63,7 @@ const createSTTLimiters = () => { max: sttUserMax, handler: createSTTHandler(false), keyGenerator: function (req) { - return req.user?.id; // Use the user ID or NULL if not available + return req.user?.id; }, store: limiterCache('stt_user_limiter'), }; diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js index 41dd9a6ba5..7ded475230 100644 --- a/api/server/middleware/limiters/ttsLimiters.js +++ b/api/server/middleware/limiters/ttsLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -54,6 +54,7 @@ const createTTSLimiters = () => { windowMs: ttsIpWindowMs, max: ttsIpMax, handler: createTTSHandler(), + keyGenerator: removePorts, store: limiterCache('tts_ip_limiter'), }; @@ -61,10 +62,10 @@ const createTTSLimiters = () => { windowMs: ttsUserWindowMs, max: ttsUserMax, handler: createTTSHandler(false), - store: limiterCache('tts_user_limiter'), keyGenerator: function (req) { - return req.user?.id; // Use the user ID or NULL if not available + return req.user?.id; }, + store: limiterCache('tts_user_limiter'), }; const ttsIpLimiter = rateLimit(ipLimiterOptions); diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js index df6987877c..8c878cfa86 100644 --- a/api/server/middleware/limiters/uploadLimiters.js +++ b/api/server/middleware/limiters/uploadLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -60,6 +60,7 @@ const createFileLimiters = () => { windowMs: fileUploadIpWindowMs, max: fileUploadIpMax, handler: createFileUploadHandler(), + keyGenerator: removePorts, store: limiterCache('file_upload_ip_limiter'), }; @@ -68,7 +69,7 @@ const createFileLimiters = () => { max: fileUploadUserMax, handler: createFileUploadHandler(false), keyGenerator: function (req) { - return req.user?.id; // Use the user ID or NULL if not available + return req.user?.id; }, store: limiterCache('file_upload_user_limiter'), }; diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js index 006c4df656..5844686bf0 100644 --- a/api/server/middleware/limiters/verifyEmailLimiter.js +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -1,7 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts } = require('~/server/utils'); +const { limiterCache, removePorts } = require('@librechat/api'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/utils/index.js b/api/server/utils/index.js index 918ab54f85..59cb71625f 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -1,4 +1,3 @@ -const removePorts = require('./removePorts'); const handleText = require('./handleText'); const sendEmail = require('./sendEmail'); const queue = require('./queue'); @@ -6,7 +5,6 @@ const files = require('./files'); module.exports = { ...handleText, - removePorts, sendEmail, ...files, ...queue, diff --git a/api/server/utils/removePorts.js b/api/server/utils/removePorts.js deleted file mode 100644 index 375ff1cc71..0000000000 --- a/api/server/utils/removePorts.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = (req) => req?.ip?.replace(/:\d+[^:]*$/, ''); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index a1412e21f2..3320fef949 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -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'; diff --git a/packages/api/src/utils/ports.spec.ts b/packages/api/src/utils/ports.spec.ts new file mode 100644 index 0000000000..ea4dc284c7 --- /dev/null +++ b/packages/api/src/utils/ports.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/api/src/utils/ports.ts b/packages/api/src/utils/ports.ts new file mode 100644 index 0000000000..ecd38039d6 --- /dev/null +++ b/packages/api/src/utils/ports.ts @@ -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; +}