This commit is contained in:
Ronit Chidara 2026-01-29 18:21:21 +02:00 committed by GitHub
commit 13cb494b9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 636 additions and 0 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.molt.bot
Status: beta.
### Changes
- Gateway: add configurable rate limiting with token bucket algorithm for abuse prevention. (#3951)
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).

View File

@ -191,6 +191,29 @@ export type GatewayHttpConfig = {
endpoints?: GatewayHttpEndpointsConfig;
};
/**
* Rate limiting configuration for the gateway.
* Uses token bucket algorithm for smooth limiting with burst support.
*/
export type GatewayRateLimitConfig = {
/** Enable rate limiting (default: true). */
enabled?: boolean;
/** Max requests/min for unauthenticated clients (default: 60, 0 = unlimited). */
unauthenticated?: number;
/** Max requests/min for authenticated clients (default: 0 = unlimited). */
authenticated?: number;
/** Max messages/min per channel (default: 200, 0 = unlimited). */
channelMessages?: number;
/** Burst multiplier for token bucket (default: 2). */
burstMultiplier?: number;
/** Auth failures before exponential backoff starts (default: 5). */
authFailuresBeforeBackoff?: number;
/** Base delay in ms for auth backoff (default: 1000). */
authBackoffBaseMs?: number;
/** Max delay in ms for auth backoff (default: 60000). */
authBackoffMaxMs?: number;
};
export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */
browser?: {
@ -233,6 +256,8 @@ export type GatewayConfig = {
tls?: GatewayTlsConfig;
http?: GatewayHttpConfig;
nodes?: GatewayNodesConfig;
/** Rate limiting configuration for abuse prevention. */
rateLimit?: GatewayRateLimitConfig;
/**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or

View File

@ -0,0 +1,265 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import {
GatewayRateLimiter,
resolveRateLimitConfig,
DEFAULT_RATE_LIMIT_CONFIG,
} from "./rate-limit.js";
describe("GatewayRateLimiter", () => {
let limiter: GatewayRateLimiter;
afterEach(() => {
limiter?.close();
});
describe("checkRequest", () => {
it("allows requests when disabled", () => {
limiter = new GatewayRateLimiter({ enabled: false });
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(Infinity);
});
it("allows unlimited authenticated requests by default", () => {
limiter = new GatewayRateLimiter({ authenticated: 0 });
for (let i = 0; i < 100; i++) {
const result = limiter.checkRequest("client-1", true);
expect(result.allowed).toBe(true);
}
});
it("rate limits unauthenticated requests", () => {
limiter = new GatewayRateLimiter({
unauthenticated: 5,
burstMultiplier: 1, // No burst for simpler testing
});
// First 5 should be allowed (burst = 5 * 1)
for (let i = 0; i < 5; i++) {
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(true);
}
// 6th should be rate limited
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("rate_limit");
expect(result.retryAfterMs).toBeGreaterThan(0);
});
it("allows burst traffic with burstMultiplier", () => {
limiter = new GatewayRateLimiter({
unauthenticated: 5,
burstMultiplier: 2, // Allow 10 requests in burst
});
// First 10 should be allowed (5 * 2 = 10)
for (let i = 0; i < 10; i++) {
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(true);
}
// 11th should be rate limited
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(false);
});
it("tracks separate buckets per client", () => {
limiter = new GatewayRateLimiter({
unauthenticated: 2,
burstMultiplier: 1,
});
// Exhaust client-1's quota
limiter.checkRequest("client-1", false);
limiter.checkRequest("client-1", false);
expect(limiter.checkRequest("client-1", false).allowed).toBe(false);
// client-2 should still have quota
expect(limiter.checkRequest("client-2", false).allowed).toBe(true);
});
it("refills tokens over time", async () => {
vi.useFakeTimers();
limiter = new GatewayRateLimiter({
unauthenticated: 60, // 1 per second
burstMultiplier: 1,
});
// Exhaust quota
for (let i = 0; i < 60; i++) {
limiter.checkRequest("client-1", false);
}
expect(limiter.checkRequest("client-1", false).allowed).toBe(false);
// Advance time by 2 seconds (should refill 2 tokens)
vi.advanceTimersByTime(2000);
expect(limiter.checkRequest("client-1", false).allowed).toBe(true);
expect(limiter.checkRequest("client-1", false).allowed).toBe(true);
expect(limiter.checkRequest("client-1", false).allowed).toBe(false);
vi.useRealTimers();
});
});
describe("checkChannelMessage", () => {
it("rate limits channel messages", () => {
limiter = new GatewayRateLimiter({
channelMessages: 3,
burstMultiplier: 1,
});
// First 3 should be allowed
for (let i = 0; i < 3; i++) {
expect(limiter.checkChannelMessage("telegram:123").allowed).toBe(true);
}
// 4th should be rate limited
expect(limiter.checkChannelMessage("telegram:123").allowed).toBe(false);
});
it("tracks separate channels independently", () => {
limiter = new GatewayRateLimiter({
channelMessages: 2,
burstMultiplier: 1,
});
// Exhaust telegram channel
limiter.checkChannelMessage("telegram:123");
limiter.checkChannelMessage("telegram:123");
expect(limiter.checkChannelMessage("telegram:123").allowed).toBe(false);
// Discord channel should still work
expect(limiter.checkChannelMessage("discord:456").allowed).toBe(true);
});
it("allows unlimited when channelMessages is 0", () => {
limiter = new GatewayRateLimiter({ channelMessages: 0 });
for (let i = 0; i < 1000; i++) {
expect(limiter.checkChannelMessage("telegram:123").allowed).toBe(true);
}
});
});
describe("auth failure backoff", () => {
it("does not apply backoff before threshold", () => {
limiter = new GatewayRateLimiter({
authFailuresBeforeBackoff: 5,
});
// Record 4 failures (below threshold)
for (let i = 0; i < 4; i++) {
limiter.recordAuthFailure("client-1");
}
// Should still be allowed
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(true);
});
it("applies backoff after threshold", () => {
limiter = new GatewayRateLimiter({
authFailuresBeforeBackoff: 3,
authBackoffBaseMs: 1000,
});
// Record 3 failures (at threshold)
for (let i = 0; i < 3; i++) {
limiter.recordAuthFailure("client-1");
}
// Should be blocked
const result = limiter.checkRequest("client-1", false);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("auth_backoff");
});
it("applies exponential backoff", () => {
limiter = new GatewayRateLimiter({
authFailuresBeforeBackoff: 1,
authBackoffBaseMs: 1000,
authBackoffMaxMs: 60000,
});
// First failure - 1000ms backoff
limiter.recordAuthFailure("client-1");
let state = limiter.getAuthBackoffState("client-1");
expect(state?.failures).toBe(1);
// Second failure - 2000ms backoff (1000 * 2^1)
limiter.recordAuthFailure("client-1");
state = limiter.getAuthBackoffState("client-1");
expect(state?.failures).toBe(2);
// Third failure - 4000ms backoff (1000 * 2^2)
limiter.recordAuthFailure("client-1");
state = limiter.getAuthBackoffState("client-1");
expect(state?.failures).toBe(3);
});
it("clears backoff after successful auth", () => {
limiter = new GatewayRateLimiter({
authFailuresBeforeBackoff: 1,
});
limiter.recordAuthFailure("client-1");
expect(limiter.getAuthBackoffState("client-1")).not.toBeNull();
limiter.clearAuthFailure("client-1");
expect(limiter.getAuthBackoffState("client-1")).toBeNull();
});
it("resets failure count after 10 minutes of inactivity", () => {
vi.useFakeTimers();
limiter = new GatewayRateLimiter({
authFailuresBeforeBackoff: 5,
});
// Record 4 failures
for (let i = 0; i < 4; i++) {
limiter.recordAuthFailure("client-1");
}
// Advance 11 minutes
vi.advanceTimersByTime(11 * 60 * 1000);
// Record 1 more failure - should be treated as first failure
limiter.recordAuthFailure("client-1");
// Should not be in backoff (only 1 failure after reset)
expect(limiter.getAuthBackoffState("client-1")).toBeNull();
vi.useRealTimers();
});
});
describe("updateConfig", () => {
it("updates configuration at runtime", () => {
limiter = new GatewayRateLimiter({ enabled: true });
expect(limiter.getConfig().enabled).toBe(true);
limiter.updateConfig({ enabled: false });
expect(limiter.getConfig().enabled).toBe(false);
});
});
});
describe("resolveRateLimitConfig", () => {
it("returns defaults when no config provided", () => {
const config = resolveRateLimitConfig();
expect(config).toEqual(DEFAULT_RATE_LIMIT_CONFIG);
});
it("merges partial config with defaults", () => {
const config = resolveRateLimitConfig({
enabled: false,
unauthenticated: 100,
});
expect(config.enabled).toBe(false);
expect(config.unauthenticated).toBe(100);
expect(config.authenticated).toBe(DEFAULT_RATE_LIMIT_CONFIG.authenticated);
});
});

345
src/gateway/rate-limit.ts Normal file
View File

@ -0,0 +1,345 @@
/**
* Gateway Rate Limiting
*
* Implements configurable rate limiting for the gateway server:
* - Per-client request rate limiting (token bucket algorithm)
* - Exponential backoff after repeated auth failures
* - Separate limits for authenticated vs unauthenticated requests
* - Channel message rate limiting
*
* Defaults are generous for power users while preventing abuse:
* - Unauthenticated: 60 req/min (prevents brute-force)
* - Authenticated: unlimited by default
* - Channel messages: 200/min per channel
*/
import type { GatewayRateLimitConfig } from "../config/types.gateway.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("rate-limit");
export type RateLimitResult = {
allowed: boolean;
remaining: number;
resetMs: number;
retryAfterMs?: number;
reason?: "rate_limit" | "auth_backoff";
};
export type ResolvedRateLimitConfig = Required<GatewayRateLimitConfig>;
/** Default rate limit configuration (generous for power users) */
export const DEFAULT_RATE_LIMIT_CONFIG: ResolvedRateLimitConfig = {
enabled: true,
unauthenticated: 60, // 60 req/min for unauthenticated (prevents brute-force)
authenticated: 0, // 0 = unlimited for authenticated users
channelMessages: 200, // 200 msg/min per channel
burstMultiplier: 2, // Allow 2x burst for short spikes
authFailuresBeforeBackoff: 5, // Start backoff after 5 failures
authBackoffBaseMs: 1000, // 1 second base delay
authBackoffMaxMs: 60000, // Max 1 minute delay
};
/** Internal state for a rate limit bucket */
type RateBucket = {
tokens: number;
lastRefillMs: number;
maxTokens: number;
refillRatePerMs: number;
};
/** Auth failure tracking per client */
type AuthFailureState = {
failures: number;
lastFailureMs: number;
backoffUntilMs: number;
};
/**
* Gateway rate limiter with per-client tracking.
* Uses token bucket algorithm for smooth rate limiting with burst support.
*/
export class GatewayRateLimiter {
private config: ResolvedRateLimitConfig;
private buckets: Map<string, RateBucket> = new Map();
private authFailures: Map<string, AuthFailureState> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(config?: Partial<GatewayRateLimitConfig>) {
this.config = resolveRateLimitConfig(config);
// Periodic cleanup of stale entries (every 5 minutes)
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
}
/**
* Check if a request should be allowed based on rate limits.
*
* @param clientId - Unique client identifier (IP address, session ID, etc.)
* @param authenticated - Whether the client is authenticated
* @returns Rate limit result with allowed status and metadata
*/
checkRequest(clientId: string, authenticated: boolean): RateLimitResult {
if (!this.config.enabled) {
return { allowed: true, remaining: Infinity, resetMs: 0 };
}
const now = Date.now();
// Check auth backoff first (applies to all requests from blocked clients)
const authState = this.authFailures.get(clientId);
if (authState && authState.backoffUntilMs > now) {
const retryAfterMs = authState.backoffUntilMs - now;
log.debug("Request blocked by auth backoff", {
clientId: clientId.slice(0, 16),
retryAfterMs,
});
return {
allowed: false,
remaining: 0,
resetMs: retryAfterMs,
retryAfterMs,
reason: "auth_backoff",
};
}
// Determine rate limit based on auth status
const ratePerMinute = authenticated ? this.config.authenticated : this.config.unauthenticated;
// 0 = unlimited
if (ratePerMinute === 0) {
return { allowed: true, remaining: Infinity, resetMs: 0 };
}
const bucketKey = `${authenticated ? "auth" : "unauth"}:${clientId}`;
const bucket = this.getOrCreateBucket(bucketKey, ratePerMinute);
// Refill tokens based on elapsed time
this.refillBucket(bucket, now);
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
resetMs: Math.ceil((bucket.maxTokens - bucket.tokens) / bucket.refillRatePerMs),
};
}
// Rate limited
const resetMs = Math.ceil((1 - bucket.tokens) / bucket.refillRatePerMs);
log.debug("Request rate limited", {
clientId: clientId.slice(0, 16),
authenticated,
resetMs,
});
return {
allowed: false,
remaining: 0,
resetMs,
retryAfterMs: resetMs,
reason: "rate_limit",
};
}
/**
* Check if a channel message should be allowed.
*
* @param channelKey - Unique channel identifier (e.g., "telegram:123")
* @returns Rate limit result
*/
checkChannelMessage(channelKey: string): RateLimitResult {
if (!this.config.enabled || this.config.channelMessages === 0) {
return { allowed: true, remaining: Infinity, resetMs: 0 };
}
const now = Date.now();
const bucketKey = `channel:${channelKey}`;
const bucket = this.getOrCreateBucket(bucketKey, this.config.channelMessages);
this.refillBucket(bucket, now);
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
resetMs: Math.ceil((bucket.maxTokens - bucket.tokens) / bucket.refillRatePerMs),
};
}
const resetMs = Math.ceil((1 - bucket.tokens) / bucket.refillRatePerMs);
log.warn("Channel message rate limited", { channelKey, resetMs });
return {
allowed: false,
remaining: 0,
resetMs,
retryAfterMs: resetMs,
reason: "rate_limit",
};
}
/**
* Record an authentication failure for exponential backoff.
*
* @param clientId - Unique client identifier
*/
recordAuthFailure(clientId: string): void {
const now = Date.now();
const state = this.authFailures.get(clientId) ?? {
failures: 0,
lastFailureMs: 0,
backoffUntilMs: 0,
};
// Reset counter if last failure was more than 10 minutes ago
if (now - state.lastFailureMs > 10 * 60 * 1000) {
state.failures = 0;
}
state.failures += 1;
state.lastFailureMs = now;
// Apply exponential backoff after threshold
if (state.failures >= this.config.authFailuresBeforeBackoff) {
const exponent = state.failures - this.config.authFailuresBeforeBackoff;
const backoffMs = Math.min(
this.config.authBackoffBaseMs * Math.pow(2, exponent),
this.config.authBackoffMaxMs,
);
state.backoffUntilMs = now + backoffMs;
log.warn("Auth failure backoff applied", {
clientId: clientId.slice(0, 16),
failures: state.failures,
backoffMs,
});
}
this.authFailures.set(clientId, state);
}
/**
* Clear auth failure state after successful authentication.
*
* @param clientId - Unique client identifier
*/
clearAuthFailure(clientId: string): void {
this.authFailures.delete(clientId);
}
/**
* Get current auth backoff state for a client.
*
* @param clientId - Unique client identifier
* @returns Backoff state or null if not in backoff
*/
getAuthBackoffState(clientId: string): { failures: number; backoffUntilMs: number } | null {
const state = this.authFailures.get(clientId);
if (!state || state.backoffUntilMs <= Date.now()) {
return null;
}
return { failures: state.failures, backoffUntilMs: state.backoffUntilMs };
}
/**
* Update configuration at runtime.
*/
updateConfig(config: Partial<GatewayRateLimitConfig>): void {
this.config = resolveRateLimitConfig(config);
log.info("Rate limit config updated", { enabled: this.config.enabled });
}
/**
* Get current configuration.
*/
getConfig(): ResolvedRateLimitConfig {
return { ...this.config };
}
/**
* Clean up resources.
*/
close(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.buckets.clear();
this.authFailures.clear();
}
private getOrCreateBucket(key: string, ratePerMinute: number): RateBucket {
let bucket = this.buckets.get(key);
if (!bucket) {
const maxTokens = ratePerMinute * this.config.burstMultiplier;
bucket = {
tokens: maxTokens, // Start full
lastRefillMs: Date.now(),
maxTokens,
refillRatePerMs: ratePerMinute / (60 * 1000), // tokens per ms
};
this.buckets.set(key, bucket);
}
return bucket;
}
private refillBucket(bucket: RateBucket, now: number): void {
const elapsedMs = now - bucket.lastRefillMs;
if (elapsedMs <= 0) return;
const tokensToAdd = elapsedMs * bucket.refillRatePerMs;
bucket.tokens = Math.min(bucket.tokens + tokensToAdd, bucket.maxTokens);
bucket.lastRefillMs = now;
}
private cleanup(): void {
const now = Date.now();
const staleThresholdMs = 10 * 60 * 1000; // 10 minutes
// Clean up stale buckets
for (const [key, bucket] of this.buckets) {
if (now - bucket.lastRefillMs > staleThresholdMs) {
this.buckets.delete(key);
}
}
// Clean up stale auth failures
for (const [key, state] of this.authFailures) {
if (now - state.lastFailureMs > staleThresholdMs && state.backoffUntilMs < now) {
this.authFailures.delete(key);
}
}
}
}
/**
* Resolve rate limit config with defaults.
*/
export function resolveRateLimitConfig(
config?: Partial<GatewayRateLimitConfig>,
): ResolvedRateLimitConfig {
return {
enabled: config?.enabled ?? DEFAULT_RATE_LIMIT_CONFIG.enabled,
unauthenticated: config?.unauthenticated ?? DEFAULT_RATE_LIMIT_CONFIG.unauthenticated,
authenticated: config?.authenticated ?? DEFAULT_RATE_LIMIT_CONFIG.authenticated,
channelMessages: config?.channelMessages ?? DEFAULT_RATE_LIMIT_CONFIG.channelMessages,
burstMultiplier: config?.burstMultiplier ?? DEFAULT_RATE_LIMIT_CONFIG.burstMultiplier,
authFailuresBeforeBackoff:
config?.authFailuresBeforeBackoff ?? DEFAULT_RATE_LIMIT_CONFIG.authFailuresBeforeBackoff,
authBackoffBaseMs: config?.authBackoffBaseMs ?? DEFAULT_RATE_LIMIT_CONFIG.authBackoffBaseMs,
authBackoffMaxMs: config?.authBackoffMaxMs ?? DEFAULT_RATE_LIMIT_CONFIG.authBackoffMaxMs,
};
}
/**
* Create a global rate limiter instance.
* This is typically called once during gateway startup.
*/
export function createGatewayRateLimiter(
config?: Partial<GatewayRateLimitConfig>,
): GatewayRateLimiter {
return new GatewayRateLimiter(config);
}