From 773750d470be7b538da5436560bd1fa97a481707 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 30 Apr 2026 07:49:45 -0400 Subject: [PATCH] Add `RATE_LIMITER_MULTIPLIER` configuration for self-hosted instances (#12226) * Add RATE_LIMIT_MULTIPLIER configuration for self-hosted instances * PR feedback --- .env.sample | 6 ++++ server/env.ts | 18 ++++++++++++ server/middlewares/rateLimiter.test.ts | 39 ++++++++++++++++++++++++++ server/middlewares/rateLimiter.ts | 7 ++++- 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index f6d37d6990..30e1044124 100644 --- a/.env.sample +++ b/.env.sample @@ -218,6 +218,12 @@ RATE_LIMITER_ENABLED=true RATE_LIMITER_REQUESTS=1000 RATE_LIMITER_DURATION_WINDOW=60 +# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values +# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed +# requests), or less than 1 to make them stricter. Effective limits are rounded +# to the nearest integer with a minimum of 1. Defaults to 1. +RATE_LIMITER_MULTIPLIER=1 + # –––––––––––––––––––––––––––––––––––––– # ––––––––––– INTEGRATIONS ––––––––––– diff --git a/server/env.ts b/server/env.ts index e2a6bdc371..deb18e32e1 100644 --- a/server/env.ts +++ b/server/env.ts @@ -580,6 +580,20 @@ export class Environment { public RATE_LIMITER_DURATION_WINDOW = this.toOptionalNumber(environment.RATE_LIMITER_DURATION_WINDOW) ?? 60; + /** + * Multiplier applied to the per-endpoint API rate limits. Allows operators to + * uniformly scale the hard-coded route-level limits up or down without + * touching code. A value of 1 (the default) preserves the built-in limits. + * Effective per-endpoint limits are scaled by this value, rounded to the + * nearest integer, and clamped to a minimum of 1. + */ + @IsOptional() + @IsNumber() + @Min(0) + @CannotUseWithout("RATE_LIMITER_ENABLED") + public RATE_LIMITER_MULTIPLIER = + this.toOptionalFloat(environment.RATE_LIMITER_MULTIPLIER) ?? 1; + /** * Set max allowed upload size for file attachments. * @deprecated Use FILE_STORAGE_UPLOAD_MAX_SIZE instead @@ -881,6 +895,10 @@ export class Environment { return value ? parseInt(value, 10) : undefined; } + protected toOptionalFloat(value: string | undefined) { + return value ? parseFloat(value) : undefined; + } + /** * Convert a string to a boolean. Supports the following: * diff --git a/server/middlewares/rateLimiter.test.ts b/server/middlewares/rateLimiter.test.ts index 6339376a1a..c745365861 100644 --- a/server/middlewares/rateLimiter.test.ts +++ b/server/middlewares/rateLimiter.test.ts @@ -7,14 +7,17 @@ import { defaultRateLimiter, rateLimiter } from "./rateLimiter"; describe("rateLimiter middleware", () => { const originalRateLimiterEnabled = env.RATE_LIMITER_ENABLED; + const originalApiMultiplier = env.RATE_LIMITER_MULTIPLIER; beforeEach(() => { env.RATE_LIMITER_ENABLED = true; + env.RATE_LIMITER_MULTIPLIER = 1; RateLimiter.rateLimiterMap.clear(); }); afterEach(() => { env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled; + env.RATE_LIMITER_MULTIPLIER = originalApiMultiplier; jest.restoreAllMocks(); }); @@ -62,6 +65,42 @@ describe("rateLimiter middleware", () => { expect(limiter.points).toBe(5); }); + it("scales the per-route limit by RATE_LIMITER_MULTIPLIER", async () => { + env.RATE_LIMITER_MULTIPLIER = 2; + + const registerMiddleware = rateLimiter({ duration: 60, requests: 5 }); + const mockCtx = { + path: "/documents.export", + mountPath: undefined, + ip: "127.0.0.1", + set: jest.fn(), + request: {}, + } as unknown as Context; + + await registerMiddleware(mockCtx, jest.fn()); + + const limiter = RateLimiter.getRateLimiter("/documents.export"); + expect(limiter.points).toBe(10); + }); + + it("rounds fractional multiplier results and never drops below 1", async () => { + env.RATE_LIMITER_MULTIPLIER = 0.1; + + const registerMiddleware = rateLimiter({ duration: 60, requests: 5 }); + const mockCtx = { + path: "/shares.subscribe", + mountPath: undefined, + ip: "127.0.0.1", + set: jest.fn(), + request: {}, + } as unknown as Context; + + await registerMiddleware(mockCtx, jest.fn()); + + const limiter = RateLimiter.getRateLimiter("/shares.subscribe"); + expect(limiter.points).toBe(1); + }); + it("should use default rate limiter when no custom rate limiter is registered", async () => { const fullPath = "/some/random/path"; expect(RateLimiter.hasRateLimiter(fullPath)).toBe(false); diff --git a/server/middlewares/rateLimiter.ts b/server/middlewares/rateLimiter.ts index 9d2eae4836..95edd36f68 100644 --- a/server/middlewares/rateLimiter.ts +++ b/server/middlewares/rateLimiter.ts @@ -118,12 +118,17 @@ export function rateLimiter(config: RateLimiterConfig) { const fullPath = `${ctx.mountPath ?? ""}${ctx.path}`; if (!RateLimiter.hasRateLimiter(fullPath)) { + const points = Math.max( + 1, + Math.round(config.requests * env.RATE_LIMITER_MULTIPLIER) + ); + RateLimiter.setRateLimiter( fullPath, defaults( { ...config, - points: config.requests, + points, }, { duration: 60,