mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add RATE_LIMITER_MULTIPLIER configuration for self-hosted instances (#12226)
* Add RATE_LIMIT_MULTIPLIER configuration for self-hosted instances * PR feedback
This commit is contained in:
@@ -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 –––––––––––
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user