Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/mcp-cloudflare/src/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import app from "./app";
describe("app", () => {
describe("GET /robots.txt", () => {
it("should return correct robots.txt content", async () => {
const res = await app.request("/robots.txt");
const res = await app.request("/robots.txt", {
headers: {
"CF-Connecting-IP": "192.0.2.1",
},
});

expect(res.status).toBe(200);

Expand All @@ -17,7 +21,11 @@ describe("app", () => {

describe("GET /llms.txt", () => {
it("should return correct llms.txt content", async () => {
const res = await app.request("/llms.txt");
const res = await app.request("/llms.txt", {
headers: {
"CF-Connecting-IP": "192.0.2.1",
},
});

expect(res.status).toBe(200);

Expand All @@ -29,7 +37,11 @@ describe("app", () => {

describe("GET /sse", () => {
it("should return deprecation message with 410 status", async () => {
const res = await app.request("/sse");
const res = await app.request("/sse", {
headers: {
"CF-Connecting-IP": "192.0.2.1",
},
});

expect(res.status).toBe(410);

Expand Down
10 changes: 5 additions & 5 deletions packages/mcp-cloudflare/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ import metadata from "./routes/metadata";
import { logIssue } from "@sentry/mcp-server/telem/logging";
import { createRequestLogger } from "./logging";
import mcpRoutes from "./routes/mcp";
import { getClientIp } from "./utils/client-ip";

const app = new Hono<{
Bindings: Env;
}>()
.use("*", createRequestLogger())
// Set user IP address from X-Real-IP header for Sentry
// Set user IP address for Sentry (optional in local dev)
.use("*", async (c, next) => {
const clientIP =
c.req.header("X-Real-IP") ||
c.req.header("CF-Connecting-IP") ||
c.req.header("X-Forwarded-For")?.split(",")[0]?.trim();
const clientIP = getClientIp(c.req.raw);

if (clientIP) {
Sentry.setUser({ ip_address: clientIP });
}
// In local development, IP extraction may fail - this is expected and safe to ignore
// as it's only used for Sentry telemetry context

await next();
})
Expand Down
27 changes: 27 additions & 0 deletions packages/mcp-cloudflare/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { Env } from "./types";
import getSentryConfig from "./sentry.config";
import { tokenExchangeCallback } from "./oauth";
import sentryMcpHandler from "./lib/mcp-handler";
import { checkRateLimit } from "./utils/rate-limiter";
import { getClientIp } from "./utils/client-ip";

// Public metadata endpoints that should be accessible from any origin
const PUBLIC_METADATA_PATHS = [
Expand Down Expand Up @@ -42,6 +44,31 @@ const corsWrappedOAuthProvider = {
}
}

// Apply rate limiting to MCP and OAuth routes
// This protects against abuse at the earliest possible point
if (url.pathname.startsWith("/mcp") || url.pathname.startsWith("/oauth")) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Rate Limiting Gap for MCP Manifested Routes

The rate limiting condition for MCP routes only checks for paths starting with /mcp. However, the application also defines MCP endpoints under /.mcp. This leaves /.mcp routes unprotected by rate limiting, creating a potential vulnerability for abuse.

Fix in Cursor Fix in Web

const clientIP = getClientIp(request);

// In local development or when IP can't be extracted, skip rate limiting
// Rate limiter is optional and primarily for production abuse prevention
if (clientIP) {
const rateLimitResult = await checkRateLimit(
clientIP,
env.MCP_RATE_LIMITER,
{
keyPrefix: "mcp",
errorMessage:
"Rate limit exceeded. Please wait before trying again.",
},
);

if (!rateLimitResult.allowed) {
return new Response(rateLimitResult.errorMessage, { status: 429 });
}
}
// If no clientIP, allow the request (likely local dev)
}

const oAuthProvider = new OAuthProvider({
apiRoute: "/mcp",
// @ts-expect-error - OAuthProvider types don't support specific Env types
Expand Down
9 changes: 6 additions & 3 deletions packages/mcp-cloudflare/src/server/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AutoRagSearchRequest,
} from "@cloudflare/workers-types";
import { logger } from "@sentry/cloudflare";
import { getClientIp } from "../utils/client-ip";

// Request schema matching the MCP tool parameters
const SearchRequestSchema = z.object({
Expand All @@ -21,14 +22,16 @@ const SearchRequestSchema = z.object({

export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
try {
// Get client IP address from Cloudflare header
const clientIP = c.req.header("CF-Connecting-IP") || "unknown";
// Get client IP address
const clientIP = getClientIp(c.req.raw);

// Rate limiting check - use client IP as the key
// In local development or when IP can't be extracted, skip rate limiting
// Rate limiter is optional and primarily for production abuse prevention
// Note: Rate limiting bindings are "unsafe" (beta) and may not be available in development
// so we check if the binding exists before using it
// https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
if (c.env.SEARCH_RATE_LIMITER) {
if (c.env.SEARCH_RATE_LIMITER && clientIP) {
try {
// Hash the IP for privacy and consistent key format
const encoder = new TextEncoder();
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-cloudflare/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ export interface Env {
CF_VERSION_METADATA: WorkerVersionMetadata;
CHAT_RATE_LIMITER: RateLimit;
SEARCH_RATE_LIMITER: RateLimit;
MCP_RATE_LIMITER: RateLimit;
AUTORAG_INDEX_NAME?: string;
}
63 changes: 63 additions & 0 deletions packages/mcp-cloudflare/src/server/utils/client-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { getClientIp } from "./client-ip";

describe("getClientIp", () => {
it("returns CF-Connecting-IP when available", () => {
const request = new Request("https://example.com", {
headers: {
"CF-Connecting-IP": "192.0.2.1",
"X-Real-IP": "192.0.2.2",
"X-Forwarded-For": "192.0.2.3, 192.0.2.4",
},
});

expect(getClientIp(request)).toBe("192.0.2.1");
});

it("returns X-Real-IP when CF-Connecting-IP not available", () => {
const request = new Request("https://example.com", {
headers: {
"X-Real-IP": "192.0.2.2",
"X-Forwarded-For": "192.0.2.3, 192.0.2.4",
},
});

expect(getClientIp(request)).toBe("192.0.2.2");
});

it("returns first IP from X-Forwarded-For when others not available", () => {
const request = new Request("https://example.com", {
headers: {
"X-Forwarded-For": "192.0.2.3, 192.0.2.4, 192.0.2.5",
},
});

expect(getClientIp(request)).toBe("192.0.2.3");
});

it("trims whitespace from X-Forwarded-For IP", () => {
const request = new Request("https://example.com", {
headers: {
"X-Forwarded-For": " 192.0.2.3 , 192.0.2.4",
},
});

expect(getClientIp(request)).toBe("192.0.2.3");
});

it("returns null when no IP headers present", () => {
const request = new Request("https://example.com");

expect(getClientIp(request)).toBe(null);
});

it("returns null when X-Forwarded-For is empty", () => {
const request = new Request("https://example.com", {
headers: {
"X-Forwarded-For": "",
},
});

expect(getClientIp(request)).toBe(null);
});
});
38 changes: 38 additions & 0 deletions packages/mcp-cloudflare/src/server/utils/client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Extract client IP address from request headers.
*
* Checks headers in priority order:
* 1. CF-Connecting-IP (Cloudflare's most reliable header)
* 2. X-Real-IP (common reverse proxy header)
* 3. X-Forwarded-For (fallback, uses first IP in list)
*
* @param request - Native Request object
* @returns Client IP address, or null if not found
*
* @example
* ```typescript
* // With native Request
* const ip = getClientIp(request);
* if (!ip) {
* throw new Error("Failed to extract client IP");
* }
*
* // With Hono Context - use c.req.raw
* const ip = getClientIp(c.req.raw);
* ```
*/
export function getClientIp(request: Request): string | null {
const cfConnectingIp = request.headers.get("CF-Connecting-IP");
if (cfConnectingIp) return cfConnectingIp;

const xRealIp = request.headers.get("X-Real-IP");
if (xRealIp) return xRealIp;

const xForwardedFor = request.headers.get("X-Forwarded-For");
if (xForwardedFor) {
const firstIp = xForwardedFor.split(",")[0]?.trim();
if (firstIp) return firstIp;
}

return null;
}
Loading