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
39 changes: 39 additions & 0 deletions docs/security.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,44 @@ SENTRY_CLIENT_SECRET=your_oauth_app_secret
COOKIE_SECRET=random_32_char_string
```

## Bot Protection

The MCP server includes bot protection at the Cloudflare Worker level to prevent abuse from generic HTTP clients.

### Implementation

Bot protection is implemented as a wrapper around the worker's fetch handler:

```typescript
export default withBotProtection(
Sentry.withSentry(
getSentryConfig,
oAuthProvider,
)
) satisfies ExportedHandler<Env>;
```

### Blocked User Agents

Generic bot user agents are blocked, including:
- Python clients: `python-requests`, `aiohttp`, `python-urllib`
- Go clients: `go-http-client`
- Java clients: `okhttp`, `apache-httpclient`
- Command line tools: `curl`, `wget`
- Other generic clients: `libwww-perl`, `bot`, `spider`, `crawler`

### Allowed Bots

Well-behaved bots are allowed, including:
- Search engines: Googlebot, Bingbot, DuckDuckBot
- Social media: FacebookExternalHit, TwitterBot
- Development tools: Postman, Insomnia
- Monitoring services: UptimeRobot, Pingdom, NewRelic

### Response

Blocked requests receive a `403 Forbidden` response with the message "Access denied".

## CORS Configuration

```typescript
Expand All @@ -165,4 +203,5 @@ const ALLOWED_ORIGINS = [
- OAuth implementation: `packages/mcp-cloudflare/src/server/routes/sentry-oauth.ts`
- Cookie utilities: `packages/mcp-cloudflare/src/server/utils/cookies.ts`
- OAuth Provider: `packages/mcp-cloudflare/src/server/bindings.ts`
- Bot protection: `packages/mcp-cloudflare/src/server/lib/bot-protection.ts`
- Sentry OAuth docs: https://docs.sentry.io/api/guides/oauth/
6 changes: 3 additions & 3 deletions packages/mcp-cloudflare/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { SCOPES } from "../constants";
import type { Env } from "./types";
import getSentryConfig from "./sentry.config";
import { withBotProtection } from "./lib/bot-protection";

Check warning on line 8 in packages/mcp-cloudflare/src/server/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/mcp-cloudflare/src/server/index.ts#L8

Added line #L8 was not covered by tests

// required for Durable Objects
export { SentryMCP };
Expand All @@ -23,7 +24,6 @@
scopesSupported: Object.keys(SCOPES),
});

export default Sentry.withSentry(
getSentryConfig,
oAuthProvider,
export default withBotProtection(
Sentry.withSentry(getSentryConfig, oAuthProvider),

Check warning on line 28 in packages/mcp-cloudflare/src/server/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/mcp-cloudflare/src/server/index.ts#L27-L28

Added lines #L27 - L28 were not covered by tests
) satisfies ExportedHandler<Env>;
245 changes: 245 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/bot-protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { describe, it, expect, vi } from "vitest";
import { withBotProtection } from "./bot-protection";
import type { Env } from "../types";
import type { IncomingRequestCfProperties } from "@cloudflare/workers-types";

describe("bot-protection", () => {
const mockEnv = {} as Env;
const mockCtx = {
waitUntil: vi.fn(),
passThroughOnException: vi.fn(),
props: {},
} as ExecutionContext;

const mockHandler: ExportedHandler<Env> = {
fetch: vi.fn().mockResolvedValue(new Response("OK")),
};

// Helper to create test requests with the proper type
const createTestRequest = (
url: string,
init?: RequestInit,
): Request<unknown, IncomingRequestCfProperties<unknown>> => {
return new Request(url, init) as Request<
unknown,
IncomingRequestCfProperties<unknown>
>;
};

describe("withBotProtection", () => {
it("should block generic Python requests user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "python-requests/2.31.0",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(await response.text()).toBe("Access denied");
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should block Go http client user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "Go-http-client/1.1",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should block okhttp user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "okhttp/4.9.3",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should block curl user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "curl/7.68.0",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should block empty user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should block very short user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "bot",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(403);
expect(mockHandler.fetch).not.toHaveBeenCalled();
});

it("should allow Chrome browser user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(await response.text()).toBe("OK");
expect(mockHandler.fetch).toHaveBeenCalledWith(request, mockEnv, mockCtx);
});

it("should allow Firefox browser user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(mockHandler.fetch).toHaveBeenCalled();
});

it("should allow Safari browser user agent", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(mockHandler.fetch).toHaveBeenCalled();
});

it("should allow Googlebot", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(mockHandler.fetch).toHaveBeenCalled();
});

it("should allow Postman", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent": "PostmanRuntime/7.32.1",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(mockHandler.fetch).toHaveBeenCalled();
});

it("should allow UptimeRobot monitoring", async () => {
const wrappedHandler = withBotProtection(mockHandler);
const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(200);
expect(mockHandler.fetch).toHaveBeenCalled();
});

it("should pass through other handler methods", () => {
const scheduledHandler = vi.fn();
const queueHandler = vi.fn();
const tailHandler = vi.fn();
const traceHandler = vi.fn();
const emailHandler = vi.fn();

const handler: ExportedHandler<Env> = {
fetch: vi.fn(),
scheduled: scheduledHandler,
queue: queueHandler,
tail: tailHandler,
trace: traceHandler,
email: emailHandler,
};

const wrappedHandler = withBotProtection(handler);

expect(wrappedHandler.scheduled).toBe(scheduledHandler);
expect(wrappedHandler.queue).toBe(queueHandler);
expect(wrappedHandler.tail).toBe(tailHandler);
expect(wrappedHandler.trace).toBe(traceHandler);
expect(wrappedHandler.email).toBe(emailHandler);
});

it("should return 501 if no fetch handler provided", async () => {
const handlerWithoutFetch: ExportedHandler<Env> = {};
const wrappedHandler = withBotProtection(handlerWithoutFetch);

const request = createTestRequest("https://example.com", {
headers: {
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});

const response = await wrappedHandler.fetch!(request, mockEnv, mockCtx);

expect(response.status).toBe(501);
expect(await response.text()).toBe("Not implemented");
});
});
});
Loading