From 58606cc9241a1e7561a22ad7928094a08d60def0 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:29:24 -0700 Subject: [PATCH] fix(serve-http): make OAuth /token rate limit configurable via env (#3114) Adds GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX and GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS to tune the /token client_credentials limiter (default unchanged: 50 req / 15 min). Invalid, zero, or negative values fall back to the default. Takeover of #2501 (mechanical rebase onto master after #2625 shifted the surrounding context in serve-http.ts). Fixes #2463. Co-authored-by: Garry Tan Co-authored-by: techtony2018 Co-authored-by: Claude Fable 5 --- src/commands/serve-http.ts | 25 ++++++++-- .../serve-http-oauth-token-rate-limit.test.ts | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 test/serve-http-oauth-token-rate-limit.test.ts diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 737f984b6..0c003bd00 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -113,6 +113,24 @@ export function shouldSuppressBootstrapPrint(opts: { return !opts.isTty; } +export type OAuthTokenRateLimitConfig = { + windowMs: number; + max: number; +}; + +function parsePositiveIntEnv(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function resolveOAuthTokenRateLimit(env: NodeJS.ProcessEnv = process.env): OAuthTokenRateLimitConfig { + return { + windowMs: parsePositiveIntEnv(env.GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), + max: parsePositiveIntEnv(env.GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX, 50), + }; +} + export type ProbeHealthResult = | { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } } | { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } }; @@ -633,12 +651,13 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // Custom client_credentials handler (before mcpAuthRouter) // SDK's token handler only supports authorization_code and refresh_token // --------------------------------------------------------------------------- + const oauthTokenRateLimit = resolveOAuthTokenRateLimit(); const ccRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 50, + windowMs: oauthTokenRateLimit.windowMs, + max: oauthTokenRateLimit.max, standardHeaders: true, legacyHeaders: false, - message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again in 15 minutes.' }, + message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again later.' }, }); // Magic-link rate limiter: 10 requests/min/IP. The bootstrap token is diff --git a/test/serve-http-oauth-token-rate-limit.test.ts b/test/serve-http-oauth-token-rate-limit.test.ts new file mode 100644 index 000000000..b40ea5016 --- /dev/null +++ b/test/serve-http-oauth-token-rate-limit.test.ts @@ -0,0 +1,46 @@ +/** + * Tests for resolveOAuthTokenRateLimit() in src/commands/serve-http.ts. + * + * The /token client_credentials limiter should keep the historical default + * while letting operators tune busy remote MCP hosts without patching source. + */ + +import { describe, test, expect } from 'bun:test'; +import { resolveOAuthTokenRateLimit } from '../src/commands/serve-http.ts'; + +describe('resolveOAuthTokenRateLimit', () => { + test('unset env keeps the historical 50 requests per 15 minutes default', () => { + expect(resolveOAuthTokenRateLimit({})).toEqual({ + windowMs: 15 * 60 * 1000, + max: 50, + }); + }); + + test('env overrides allow a busy host to use 200 requests per minute', () => { + expect(resolveOAuthTokenRateLimit({ + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS: '60000', + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX: '200', + })).toEqual({ + windowMs: 60_000, + max: 200, + }); + }); + + test('blank, non-numeric, zero, and negative values fall back safely', () => { + expect(resolveOAuthTokenRateLimit({ + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS: '', + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX: 'nope', + })).toEqual({ + windowMs: 15 * 60 * 1000, + max: 50, + }); + + expect(resolveOAuthTokenRateLimit({ + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS: '0', + GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX: '-10', + })).toEqual({ + windowMs: 15 * 60 * 1000, + max: 50, + }); + }); +});