fix: replace the broken per-key API rate limit with a real 5000/min per-user /mcp limit (#545)
This commit is contained in:
parent
f35bbb8753
commit
9b12e073a8
@ -468,6 +468,15 @@ export default Alchemy.Stack(
|
|||||||
// name).
|
// name).
|
||||||
AUDIT_ENGINE: auditWorker,
|
AUDIT_ENGINE: auditWorker,
|
||||||
|
|
||||||
|
// Per-user throttle for /mcp API-key auth (see
|
||||||
|
// src/server/mcp/api-key-auth.ts). Only this stack declares the
|
||||||
|
// binding — the wrangler.jsonc surfaces (local dev, Docker
|
||||||
|
// self-host) skip limiting when it's absent.
|
||||||
|
MCP_RATE_LIMIT: Cloudflare.RateLimit("MCP_RATE_LIMIT", {
|
||||||
|
namespaceId: 1001,
|
||||||
|
simple: { limit: 5000, period: 60 },
|
||||||
|
}),
|
||||||
|
|
||||||
// Durable Objects (the chat agents; the audit scratchpad lives
|
// Durable Objects (the chat agents; the audit scratchpad lives
|
||||||
// privately in the open-seo-audit worker). Alchemy backs new DO
|
// privately in the open-seo-audit worker). Alchemy backs new DO
|
||||||
// classes with SQLite storage; wrangler.jsonc's `migrations` only
|
// classes with SQLite storage; wrangler.jsonc's `migrations` only
|
||||||
|
|||||||
@ -10,10 +10,20 @@ export function createApiKeyPlugin() {
|
|||||||
// Stored display prefix ("oseo_" + 4 key chars) shown in Settings so keys
|
// Stored display prefix ("oseo_" + 4 key chars) shown in Settings so keys
|
||||||
// are tellable apart; the plugin default of 6 barely clears the prefix.
|
// are tellable apart; the plugin default of 6 barely clears the prefix.
|
||||||
startingCharactersConfig: { shouldStore: true, charactersLength: 9 },
|
startingCharactersConfig: { shouldStore: true, charactersLength: 9 },
|
||||||
rateLimit: {
|
// The plugin's own rate limit is off; the /mcp handler enforces a real
|
||||||
enabled: true,
|
// 5000-per-minute per-user limit via Cloudflare's rate-limit binding
|
||||||
timeWindow: 60 * 1000,
|
// instead (see server/mcp/api-key-auth.ts; hosted prod only). This
|
||||||
maxRequests: 500,
|
// limiter is disabled because it is broken for steady traffic: its
|
||||||
},
|
// window resets only after a full timeWindow of complete idle (it
|
||||||
|
// compares against the previous request, not a window start), so
|
||||||
|
// "500 / 60s" really meant "500 requests without a 60s pause". An active
|
||||||
|
// MCP session never pauses that long, so keys hit the cap and were
|
||||||
|
// hard-blocked for a minute at a time. It also never protected against
|
||||||
|
// invalid keys — it ran only AFTER the hashed key matched a row.
|
||||||
|
//
|
||||||
|
// Raising maxRequests instead would not have worked: the limits are
|
||||||
|
// snapshotted onto each apikey row at creation, whereas `enabled: false`
|
||||||
|
// is read from this live config first — it frees existing keys too.
|
||||||
|
rateLimit: { enabled: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,16 @@ function createAuth() {
|
|||||||
const auth = betterAuth({
|
const auth = betterAuth({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
secret: getHostedSecret(),
|
secret: getHostedSecret(),
|
||||||
|
// The api-key plugin logs every verification failure at error level — a
|
||||||
|
// stale key or a throttled caller included. The /mcp handler already logs
|
||||||
|
// the response it returns at the right level (debug for 401, warn for 429),
|
||||||
|
// so drop the duplicate.
|
||||||
|
logger: {
|
||||||
|
log: (level, message, ...args) => {
|
||||||
|
if (message.startsWith("Failed to validate API key")) return;
|
||||||
|
console[level](`[better-auth] ${message}`, ...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
...baseAuthConfig,
|
...baseAuthConfig,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
...baseAuthConfig.emailAndPassword,
|
...baseAuthConfig.emailAndPassword,
|
||||||
|
|||||||
@ -146,25 +146,23 @@ describe("handleMcpApiKeyRequest", () => {
|
|||||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 429 with Retry-After when Better Auth rate-limits the key", async () => {
|
it("returns 429 with Retry-After when the MCP_RATE_LIMIT binding denies", async () => {
|
||||||
mocks.verifyApiKey.mockResolvedValue({
|
mocks.verifyApiKey.mockResolvedValue({
|
||||||
valid: false,
|
valid: true,
|
||||||
error: {
|
error: null,
|
||||||
code: "RATE_LIMITED",
|
key: { referenceId: "user-1" },
|
||||||
message: "Rate limit exceeded",
|
|
||||||
details: { tryAgainIn: 30500 },
|
|
||||||
},
|
|
||||||
key: null,
|
|
||||||
});
|
});
|
||||||
|
const limit = vi.fn().mockResolvedValue({ success: false });
|
||||||
|
|
||||||
const response = await handleMcpApiKeyRequest(
|
const response = await handleMcpApiKeyRequest(
|
||||||
request({ "x-api-key": "oseo_limited" }),
|
request({ "x-api-key": "oseo_limited" }),
|
||||||
env,
|
{ MCP_RATE_LIMIT: { limit } },
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(limit).toHaveBeenCalledWith({ key: "user-1" });
|
||||||
expect(response?.status).toBe(429);
|
expect(response?.status).toBe(429);
|
||||||
expect(response?.headers.get("Retry-After")).toBe("31");
|
expect(response?.headers.get("Retry-After")).toBe("60");
|
||||||
await expect(response?.json()).resolves.toMatchObject({
|
await expect(response?.json()).resolves.toMatchObject({
|
||||||
error: "rate_limited",
|
error: "rate_limited",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -95,6 +95,26 @@ export async function handleMcpApiKeyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = result.key.referenceId;
|
const userId = result.key.referenceId;
|
||||||
|
|
||||||
|
// Per-user request throttle. The binding is declared in alchemy.run.ts
|
||||||
|
// (hosted prod only); local dev and self-host run without it and skip
|
||||||
|
// limiting, which is fine single-user. This replaces the better-auth
|
||||||
|
// plugin limiter, whose broken idle-gap window hard-blocked active MCP
|
||||||
|
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
|
||||||
|
// best-effort, which is all this needs to be: credits bound spend, this
|
||||||
|
// bounds runaway request volume.
|
||||||
|
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
|
||||||
|
if (rateLimit) {
|
||||||
|
const { success } = await rateLimit.limit({ key: userId });
|
||||||
|
if (!success) {
|
||||||
|
return apiKeyErrorResponse({
|
||||||
|
code: "RATE_LIMITED",
|
||||||
|
message: "Rate limit exceeded: 5000 requests per minute",
|
||||||
|
details: { tryAgainIn: 60_000 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await AuthRepository.getHostedUser(userId);
|
const user = await AuthRepository.getHostedUser(userId);
|
||||||
if (!user?.email) return apiKeyErrorResponse(null);
|
if (!user?.email) return apiKeyErrorResponse(null);
|
||||||
|
|
||||||
|
|||||||
3
worker-configuration.d.ts
vendored
3
worker-configuration.d.ts
vendored
@ -20,6 +20,9 @@ declare namespace Cloudflare {
|
|||||||
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: string;
|
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: string;
|
||||||
POSTHOG_HOST: string;
|
POSTHOG_HOST: string;
|
||||||
POSTHOG_PUBLIC_KEY: string;
|
POSTHOG_PUBLIC_KEY: string;
|
||||||
|
// Hand-patched: hosted-prod-only binding declared in alchemy.run.ts,
|
||||||
|
// absent in the wrangler.jsonc surfaces (local dev, Docker self-host).
|
||||||
|
MCP_RATE_LIMIT?: RateLimit;
|
||||||
// Hand-patched: the class moved to src/audit-worker.ts and a full regen pulls
|
// Hand-patched: the class moved to src/audit-worker.ts and a full regen pulls
|
||||||
// unrelated runtime-type drift (see cf-typegen); keep this in sync until then.
|
// unrelated runtime-type drift (see cf-typegen); keep this in sync until then.
|
||||||
SITE_AUDIT_WORKFLOW: Workflow<Parameters<import("./src/audit-worker").SiteAuditWorkflow['run']>[0]['payload']>;
|
SITE_AUDIT_WORKFLOW: Workflow<Parameters<import("./src/audit-worker").SiteAuditWorkflow['run']>[0]['payload']>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user