Skip to content

Commit df6c199

Browse files
alistair3149claude
andcommitted
Close two ways a request could dodge the rate limiter
Review found both live: on a drained bucket, a JSON-RPC batch of 25 tool calls returned 25 results, and a request sent as `application/json;` ran its tool every time. Either one made the limiter decorative. The legacy stateless leg executes a batch entry by entry, so a batch now costs one token per tools/call entry and is refused whole when the cost does not fit — wrapping calls in brackets buys nothing. The content-type hole was a mismatch between two predicates: the SDK accepts a header body-parser's default matcher rejects, so express left req.body undefined, the limiter saw no tool call, and the handler read the raw stream anyway. express.json now types on the SDK's own isJsonContentType, which cannot drift from what the handler accepts. That also restores MCP_MAX_REQUEST_BODY, which the same gap had been bypassing. Two further holes the review surfaced. A forwarded bearer is never verified by this server, so a caller minting a random token per request minted a fresh allowance per request; forwarded bearers now charge a shared allowance as well as their own, capping the whole deprecated path. And an overflow key arriving when every tracked bucket was mid-drain fell back to the anonymous bucket, which passes everything when anonymous limiting is switched off; it is now refused outright. The refusal also reports which allowance rejected it, so mcp_rate_limited_total stops labelling a shared-bucket refusal as the caller's own. The regression tests drive the real buildApp: both bypasses were invisible to tests that stubbed the handler or built their own express app, and a first attempt at a content-type test reproduced that mistake — it inlined the fix in its own harness and passed against the unfixed server. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 81dfaff commit df6c199

9 files changed

Lines changed: 451 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1313

1414
### Breaking changes
1515

16-
- The HTTP transport now rate limits `tools/call`: each signed-in user gets their own allowance (default 30 per second, burst 60) and anonymous callers share one (default 100 per second). A request over the limit is refused with `429` and a `Retry-After` header. Raise `MCP_RATE_LIMIT` / `MCP_RATE_LIMIT_BURST` / `MCP_RATE_LIMIT_ANONYMOUS` if you run high-throughput automation, or set `MCP_RATE_LIMIT=0` to disable.
16+
- The HTTP transport now rate limits `tools/call`: each authenticated caller gets its own allowance (default 30 per second, burst 60) and anonymous callers share one (default 100 per second). A request over the limit is refused with `429` and a `Retry-After` header. Raise `MCP_RATE_LIMIT` / `MCP_RATE_LIMIT_BURST` / `MCP_RATE_LIMIT_ANONYMOUS` if you run high-throughput automation, or set `MCP_RATE_LIMIT=0` to disable.
1717
- The HTTP transport no longer forwards a caller's `Authorization: Bearer` header to MediaWiki. Such a request is refused with `401`, because a token minted by the wiki was not issued for this server. Use [hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in), or set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; it is deprecated and will be removed. The server now warns at startup when a wiki requires a signed-in user but neither hosted sign-in nor forwarding is available, since no request could then succeed.
1818
- The server no longer advertises the wikis' own authorization servers, so a client can no longer discover where to mint a token to send here. Without hosted OAuth sign-in enabled, `/.well-known/oauth-protected-resource` now answers `404`, and `list-wikis` stops reporting each wiki's `authorizationServer`. Deployments running the hosted sign-in are unaffected.
1919
- The `Origin` header is now validated on every bind, and a request carrying an unlisted origin is refused with `403`. If you serve a browser-based client from a public bind, set `MCP_ALLOWED_ORIGINS` before upgrading. Clients that send no `Origin` header, which is most of them, are unaffected.

docs/deployment.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Serve a single wiki for anonymous reads: no sign-in, no writes. Good for public
4949

5050
`readOnly: true` together with `allowWikiManagement: false` hides the wiki-management tools (`add-wiki`, `remove-wiki`) and the six write tools (`create-page`, `update-page`, `delete-page`, `undelete-page`, `upload-file`, `upload-file-from-url`) from `tools/list`. What remains is an anonymous, read-only interface.
5151

52-
Then run it with `MCP_TRANSPORT=http` behind a reverse proxy that terminates TLS (Cloudflare, nginx, and Caddy all work), then set the [Host and Origin allowlists](#security-checklist). The server [rate limits tool calls itself](#rate-limiting); IP-level limiting against anonymous floods still belongs at the proxy, which knows the caller's address when this server does not.
52+
Then run it with `MCP_TRANSPORT=http` behind a reverse proxy that terminates TLS (Cloudflare, nginx, and Caddy all work), then set the [Host and Origin allowlists](#security-checklist). The server itself [rate limits tool calls](#rate-limiting); IP-level limiting against anonymous floods still belongs at the proxy, which knows the caller's address when this server does not.
5353

5454
## Hosted OAuth sign-in
5555

@@ -242,9 +242,9 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
242242
| `MCP_TRUSTED_HOSTS` | unset | Comma-separated **outbound** SSRF-guard exemptions for internal destinations (e.g. `mediawiki.svc`). See [Outbound SSRF guard](#outbound-ssrf-guard). |
243243
| `MCP_ALLOW_STATIC_FALLBACK` | unset | Allow HTTP startup when a wiki has static credentials, making them a shared fallback identity. See [Security checklist](#security-checklist). |
244244
| `MCP_ALLOW_BEARER_PASSTHROUGH` | unset | Deprecated. Forward a caller's `Authorization` header to MediaWiki as that caller. Without it such a request is refused with `401`. See [Per-request bearer token](#per-request-bearer-token-http-transport-deprecated). |
245-
| `MCP_RATE_LIMIT` | `30` | Sustained `tools/call` per second per signed-in caller. `0` disables rate limiting. See [Rate limiting](#rate-limiting). |
245+
| `MCP_RATE_LIMIT` | `30` | Sustained `tools/call` per second per authenticated caller. `0` disables rate limiting. See [Rate limiting](#rate-limiting). |
246246
| `MCP_RATE_LIMIT_BURST` | 2 × rate | How far a caller's burst can run ahead of the sustained rate. |
247-
| `MCP_RATE_LIMIT_ANONYMOUS` | `100` | Sustained `tools/call` per second across **all** anonymous callers combined (burst 2 ×). `0` leaves anonymous traffic unlimited. |
247+
| `MCP_RATE_LIMIT_ANONYMOUS` | `100` | Sustained `tools/call` per second across **all** anonymous callers combined (burst 2 × the rate, not separately tunable). `0` leaves anonymous traffic unlimited. |
248248

249249
`MCP_MAX_REQUEST_BODY` matches nginx's `client_max_body_size 1m`. Raise it if `update-page` calls return 413 on legitimately large edits or your wiki has raised `$wgMaxArticleSize` (MediaWiki default 2 MB). Lower it for a tighter DoS guard.
250250

@@ -313,9 +313,9 @@ A request carrying an `Origin` the server cannot parse at all is rejected with a
313313

314314
### Rate limiting
315315

316-
`tools/call` is rate limited per caller: each signed-in user gets their own allowance (`MCP_RATE_LIMIT`, burst `MCP_RATE_LIMIT_BURST`), and all anonymous callers share one (`MCP_RATE_LIMIT_ANONYMOUS`). A request over the limit is refused with `429` and a `Retry-After` header, and never reaches the wiki. Discovery calls and subscription streams are not limited.
316+
`tools/call` is rate limited per caller: each authenticated caller gets its own allowance (`MCP_RATE_LIMIT`, burst `MCP_RATE_LIMIT_BURST`), and all anonymous callers share one (`MCP_RATE_LIMIT_ANONYMOUS`). A request over the limit is refused with `429` and a `Retry-After` header, and never reaches the wiki. Only `tools/call` is limited; every other request, including subscription streams, passes untouched.
317317

318-
The split follows who can see what: only this server knows which signed-in user a request acts as, so per-user fairness lives here; only the reverse proxy can tell anonymous callers apart by IP address, so the anonymous allowance is a flood backstop for the wiki, not fairness between anonymous callers. The limiter is per-process — replicas each enforce their own allowance — and `mcp_rate_limited_total` on [`/metrics`](operations.md#metrics) counts refusals for tuning.
318+
The limiter is per-process — replicas each enforce their own allowance — and `mcp_rate_limited_total` on [`/metrics`](operations.md#metrics) counts refusals for tuning.
319319

320320
### v1 limitations
321321

@@ -346,7 +346,7 @@ Authorization: Bearer <oauth2-access-token>
346346

347347
Use a MediaWiki OAuth2 access token obtained from `Special:OAuthConsumerRegistration/propose/oauth2` on the target wiki, with [Extension:OAuth](https://www.mediawiki.org/wiki/Extension:OAuth) installed. The server forwards it to MediaWiki as that caller's token, so writes are attributable and MediaWiki's per-user rate limits apply. A bearer is scoped to a single MediaWiki OAuth2 realm, and the server pins nothing across requests: one client can address wikis on different authorization servers by sending the right token per request. While forwarding is enabled, `list-wikis` reports each OAuth wiki's `authorizationServer` so a caller can see which realm a wiki belongs to.
348348

349-
No OAuth discovery leads here: only [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes a protected-resource document, and it names this server, so an OAuth-aware client is never steered into minting a wiki token to present here. Obtain the token yourself and configure it on the caller. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
349+
No OAuth discovery points at the wikis' authorization servers: the only protected-resource document is the one [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes, and it names this server. A client cannot discover where to mint a wiki token — obtain it yourself and configure it on the caller. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
350350

351351
**Precedence:** request header (only while `MCP_ALLOW_BEARER_PASSTHROUGH=true`; otherwise refused with `401`) → `config.json` `token``config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why.
352352

docs/operations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Exposed series:
9090
- `mcp_inflight_requests` — gauge of `/mcp` requests currently being served. Subscription streams are excluded: they are held open by design.
9191
- `mcp_subscription_streams` — gauge of open change-notification streams (`subscriptions/listen`), the closest measure of connected clients.
9292
- `mcp_ready_failures_total` — counter of `/ready` probes that returned non-200.
93-
- `mcp_rate_limited_total{caller}` — counter of `tools/call` requests refused with `429`, labelled `caller` or `anonymous`. A rising `caller` series means signed-in users hit `MCP_RATE_LIMIT`; a rising `anonymous` series means the shared backstop is engaging.
93+
- `mcp_rate_limited_total{caller}` — counter of `tools/call` requests refused with `429`; the `caller` label is `caller` for authenticated callers and `anonymous` for the shared bucket. A rising `caller` series means authenticated callers hit `MCP_RATE_LIMIT`; a rising `anonymous` series means the shared backstop is engaging.
9494
- `mcp_proxy_store_upstream_tokens` — gauge of upstream MediaWiki tokens held in the hosted OAuth proxy store. This set grows with cumulative sign-ins over the process lifetime; watch it to size memory and the flush cost below.
9595
- `mcp_proxy_store_clients` — gauge of registered clients held in the hosted OAuth proxy store. FIFO-capped at 10,000, so this plateaus rather than growing without bound.
9696
- `mcp_proxy_store_flush_duration_seconds` — histogram of hosted-proxy store durable-flush durations (serialize + encrypt + write). Every upstream-token write flushes the whole store synchronously, so this scales with the token count above. Records successful flushes only.

src/transport/mcpRoute.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
RATE_LIMITED_ERROR_CODE,
1414
UPSTREAM_UNAVAILABLE_ERROR_CODE,
1515
} from './errorCodes.ts';
16-
import type { RateLimiter } from './rateLimit.ts';
16+
import type { RateLimitDecision, RateLimiter } from './rateLimit.ts';
1717
import type { ProxyConfig } from '../auth/authorizationServer/proxyConfig.ts';
1818
import type { ProxyStore } from '../auth/authorizationServer/proxyStore.ts';
1919
import {
@@ -130,6 +130,23 @@ function emit503Unavailable(req: Request, res: Response): void {
130130
});
131131
}
132132

133+
// One shared allowance for every caller-supplied bearer, charged alongside the
134+
// per-token bucket. This server cannot verify those tokens, so without it a
135+
// caller minting a random bearer per request would never meet a limit.
136+
const PASSTHROUGH_SHARED_KEY = 'bearer:shared';
137+
138+
// The refusal that should be reported when two allowances are charged: a refusal
139+
// beats an admission, and the longer wait beats the shorter.
140+
function worstOf(a: RateLimitDecision, b: RateLimitDecision): RateLimitDecision {
141+
if (a.allowed) {
142+
return b;
143+
}
144+
if (b.allowed) {
145+
return a;
146+
}
147+
return a.retryAfterSeconds >= b.retryAfterSeconds ? a : b;
148+
}
149+
133150
function emit429RateLimited(req: Request, res: Response, retryAfterSeconds: number): void {
134151
res.set('Retry-After', String(retryAfterSeconds));
135152
res.status(429).json({
@@ -146,15 +163,25 @@ function emit429RateLimited(req: Request, res: Response, retryAfterSeconds: numb
146163
// requirement names, and it is the one that reaches the wiki. Discovery,
147164
// initialize, cancellations and the held-open subscriptions/listen stream all
148165
// pass untouched — a stream that consumed a token would hold it forever.
149-
function isToolsCallRequest(body: unknown): boolean {
166+
function isToolsCallMessage(message: unknown): boolean {
150167
return (
151-
typeof body === 'object' &&
152-
body !== null &&
153-
!Array.isArray(body) &&
154-
(body as { method?: unknown }).method === 'tools/call'
168+
typeof message === 'object' &&
169+
message !== null &&
170+
(message as { method?: unknown }).method === 'tools/call'
155171
);
156172
}
157173

174+
// How many tokens a request costs. The legacy stateless leg executes a JSON-RPC
175+
// batch array entry by entry, so a batch must be charged per tools/call entry:
176+
// counting the array as one request would let any caller wrap N calls in
177+
// brackets and pay for one.
178+
function toolsCallCost(body: unknown): number {
179+
if (Array.isArray(body)) {
180+
return body.filter(isToolsCallMessage).length;
181+
}
182+
return isToolsCallMessage(body) ? 1 : 0;
183+
}
184+
158185
export interface McpRouteOptions {
159186
wikiRegistry?: WikiRegistry;
160187
// When the hosted OAuth proxy is enabled, the /mcp bearer is a proxy-minted
@@ -277,21 +304,35 @@ export function createMcpRouteHandler(
277304
}
278305

279306
// A bearer that survived to here outside the proxy path is being forwarded
280-
// under the deprecated opt-in. Its bucket is keyed on a digest of the token
281-
// itself — the only identity available; a caller that rotates tokens
282-
// rotates buckets, which is one more way the passthrough shape falls short
283-
// of the hosted sign-in.
307+
// under the deprecated opt-in. This server never verifies it, so a digest
308+
// of the token is the only identity available.
309+
let forwardedBearer = false;
284310
if (rateLimitKey === undefined && resolvedBearer !== undefined && !(pc && proxyStore)) {
285311
const digest = createHash('sha256').update(resolvedBearer).digest('hex').slice(0, 32);
286312
rateLimitKey = `bearer:${digest}`;
313+
forwardedBearer = true;
287314
}
288315

289-
if (rateLimiter && req.method === 'POST' && isToolsCallRequest(req.body)) {
290-
const decision = rateLimiter.take(rateLimitKey);
291-
if (!decision.allowed) {
292-
recordRateLimited(rateLimitKey === undefined ? 'anonymous' : 'caller');
293-
emit429RateLimited(req, res, decision.retryAfterSeconds);
294-
return;
316+
if (rateLimiter && req.method === 'POST') {
317+
const cost = toolsCallCost(req.body);
318+
if (cost > 0) {
319+
// An unverified bearer would otherwise mint a fresh allowance per
320+
// request: rotating random tokens rotates digests. Charging a shared
321+
// bucket as well caps the whole forwarding path, while the per-digest
322+
// bucket still keeps distinct legitimate callers apart. Refusing on
323+
// either leaves the other's token spent — an accounting slip that
324+
// only ever makes the limiter stricter under abuse.
325+
const decision = forwardedBearer
326+
? worstOf(
327+
rateLimiter.take(PASSTHROUGH_SHARED_KEY, cost),
328+
rateLimiter.take(rateLimitKey, cost),
329+
)
330+
: rateLimiter.take(rateLimitKey, cost);
331+
if (!decision.allowed) {
332+
recordRateLimited(decision.bucket);
333+
emit429RateLimited(req, res, decision.retryAfterSeconds);
334+
return;
335+
}
295336
}
296337
}
297338

src/transport/rateLimit.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ export interface RateLimitSettings {
1818
anonymousBurst: number;
1919
}
2020

21-
export type RateLimitDecision = { allowed: true } | { allowed: false; retryAfterSeconds: number };
21+
export type RateLimitDecision =
22+
| { allowed: true }
23+
// `bucket` names which allowance refused, so the metric can tell a caller
24+
// hitting its own limit from one refused by the shared bucket.
25+
| { allowed: false; retryAfterSeconds: number; bucket: 'caller' | 'anonymous' };
2226

2327
export interface RateLimiter {
24-
// key identifies the authenticated caller; undefined means anonymous.
25-
take(key: string | undefined): RateLimitDecision;
28+
// key identifies the authenticated caller; undefined means anonymous. cost is
29+
// how many tool calls the request carries: a JSON-RPC batch charges one token
30+
// per tools/call entry, so wrapping calls in an array buys nothing.
31+
take(key: string | undefined, cost?: number): RateLimitDecision;
2632
}
2733

2834
interface Bucket {
@@ -34,25 +40,44 @@ interface Bucket {
3440
// tracks real signed-in users — the cap is a backstop, not an expected ceiling.
3541
const MAX_TRACKED_KEYS = 10_000;
3642

43+
// Retry-After must be a finite number of seconds. A directly-constructed
44+
// settings object could carry a zero or negative rate, making the deficit
45+
// division non-finite; clamp rather than emit an unparseable header value.
46+
const MAX_RETRY_AFTER_SECONDS = 3600;
47+
3748
function refill(bucket: Bucket, ratePerSecond: number, burst: number, now: number): void {
3849
bucket.tokens = Math.min(burst, bucket.tokens + ((now - bucket.last) / 1000) * ratePerSecond);
3950
bucket.last = now;
4051
}
4152

53+
function retryAfterFor(deficit: number, ratePerSecond: number): number {
54+
const seconds = Math.ceil(deficit / ratePerSecond);
55+
if (!Number.isFinite(seconds)) {
56+
return MAX_RETRY_AFTER_SECONDS;
57+
}
58+
return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, seconds));
59+
}
60+
4261
function takeFrom(
4362
bucket: Bucket,
4463
ratePerSecond: number,
4564
burst: number,
4665
now: number,
66+
cost: number,
67+
which: 'caller' | 'anonymous',
4768
): RateLimitDecision {
4869
refill(bucket, ratePerSecond, burst, now);
49-
if (bucket.tokens >= 1) {
50-
bucket.tokens -= 1;
70+
if (bucket.tokens >= cost) {
71+
bucket.tokens -= cost;
5172
return { allowed: true };
5273
}
74+
// A cost above `burst` can never be satisfied however long the caller waits,
75+
// so such a batch has to be split; the deficit still gives a truthful lower
76+
// bound. Nothing is consumed on a refusal.
5377
return {
5478
allowed: false,
55-
retryAfterSeconds: Math.max(1, Math.ceil((1 - bucket.tokens) / ratePerSecond)),
79+
retryAfterSeconds: retryAfterFor(cost - bucket.tokens, ratePerSecond),
80+
bucket: which,
5681
};
5782
}
5883

@@ -63,12 +88,13 @@ export function createRateLimiter(
6388
const { ratePerSecond, burst, anonymousRatePerSecond, anonymousBurst } = settings;
6489
const buckets = new Map<string, Bucket>();
6590
const anonymous: Bucket = { tokens: anonymousBurst, last: now() };
91+
const anonymousLimited = anonymousRatePerSecond > 0;
6692

67-
function takeAnonymous(at: number): RateLimitDecision {
68-
if (anonymousRatePerSecond <= 0) {
93+
function takeAnonymous(at: number, cost: number): RateLimitDecision {
94+
if (!anonymousLimited) {
6995
return { allowed: true };
7096
}
71-
return takeFrom(anonymous, anonymousRatePerSecond, anonymousBurst, at);
97+
return takeFrom(anonymous, anonymousRatePerSecond, anonymousBurst, at, cost, 'anonymous');
7298
}
7399

74100
// Evicts only buckets that have refilled to capacity: a full bucket is
@@ -85,26 +111,34 @@ export function createRateLimiter(
85111
}
86112

87113
return {
88-
take(key: string | undefined): RateLimitDecision {
114+
take(key: string | undefined, cost = 1): RateLimitDecision {
115+
if (cost <= 0) {
116+
return { allowed: true };
117+
}
89118
const at = now();
90119
if (key === undefined) {
91-
return takeAnonymous(at);
120+
return takeAnonymous(at, cost);
92121
}
93122
let bucket = buckets.get(key);
94123
if (!bucket) {
95124
if (buckets.size >= MAX_TRACKED_KEYS) {
96125
sweep(at);
97126
if (buckets.size >= MAX_TRACKED_KEYS) {
98-
// Every tracked bucket is mid-drain. Overflow keys share the
99-
// anonymous bucket rather than growing the map or passing free:
100-
// memory stays bounded and the overflow caller is still limited.
101-
return takeAnonymous(at);
127+
// Every tracked bucket is mid-drain, so admitting this key would
128+
// mean growing the map without bound. Refuse rather than fall back
129+
// to the anonymous bucket, which passes everything when anonymous
130+
// limiting is switched off.
131+
return {
132+
allowed: false,
133+
retryAfterSeconds: retryAfterFor(burst, ratePerSecond),
134+
bucket: 'caller',
135+
};
102136
}
103137
}
104138
bucket = { tokens: burst, last: at };
105139
buckets.set(key, bucket);
106140
}
107-
return takeFrom(bucket, ratePerSecond, burst, at);
141+
return takeFrom(bucket, ratePerSecond, burst, at, cost, 'caller');
108142
},
109143
};
110144
}

0 commit comments

Comments
 (0)