From 25c31c9bc230cf2f5023ff9c3c321ef68a37962a Mon Sep 17 00:00:00 2001 From: Lin & Lay Date: Thu, 26 Mar 2026 22:13:24 +0900 Subject: [PATCH] feat(worker): add bearer auth, GitHub IP allowlist, and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セキュリティ基盤強化: マルチテナント公開サービス化 (#62) の第一歩として、 3層の防御レイヤーを追加。 1. Bearer Token 認証 — /events, /mcp エンドポイントに MCP_AUTH_TOKEN による認証を追加。 未設定時はスキップ(後方互換)。WebSocket は ?token= クエリパラメータも対応。 2. GitHub IP ホワイトリスト — /webhooks/github は GitHub の IP レンジのみ許可。 api.github.com/meta から取得し Cache API でキャッシュ。フォールバック用ハードコード IP あり。 3. レート制限 — IP ベースのスライディングウィンドウ方式。 webhook: 300req/分、API: 120req/分。 ローカルブリッジ (local-mcp, mcp-server) も WEBHOOK_AUTH_TOKEN 環境変数で Authorization ヘッダーを自動付与するよう対応。 Refs #62 Refs sub #70 Refs sub #72 Co-Authored-By: Claude Opus 4.6 (1M context) --- local-mcp/src/index.ts | 19 +++-- mcp-server/server/index.js | 21 ++++-- worker/src/github-ip.ts | 145 +++++++++++++++++++++++++++++++++++++ worker/src/index.ts | 46 ++++++++++++ worker/src/rate-limit.ts | 74 +++++++++++++++++++ 5 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 worker/src/github-ip.ts create mode 100644 worker/src/rate-limit.ts diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index 9c9a501..bd52a0a 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -18,6 +18,14 @@ import WebSocket from "ws"; const WORKER_URL = process.env.WEBHOOK_WORKER_URL || "https://github-webhook-mcp.liplus.workers.dev"; const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; +const AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN || ""; + +/** Build common headers with optional Bearer auth */ +function authHeaders(extra?: Record): Record { + const h: Record = { ...extra }; + if (AUTH_TOKEN) h["Authorization"] = `Bearer ${AUTH_TOKEN}`; + return h; +} // ── Remote MCP Session (lazy, reused) ──────────────────────────────────────── @@ -28,10 +36,10 @@ async function getSessionId(): Promise { const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: { + headers: authHeaders({ "Content-Type": "application/json", "Accept": "application/json, text/event-stream", - }, + }), body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", @@ -53,11 +61,11 @@ async function callRemoteTool(name: string, args: Record): Prom const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: { + headers: authHeaders({ "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": sessionId, - }, + }), body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", @@ -166,7 +174,8 @@ function connectWebSocket() { let pingTimer: ReturnType | null = null; function connect() { - ws = new WebSocket(wsUrl); + const wsOptions = AUTH_TOKEN ? { headers: { "Authorization": `Bearer ${AUTH_TOKEN}` } } : undefined; + ws = new WebSocket(wsUrl, wsOptions); ws.on("open", () => { // Send periodic pings to keep connection alive diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index e89b59d..a65f5d6 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -19,6 +19,14 @@ const WORKER_URL = process.env.WEBHOOK_WORKER_URL || "https://github-webhook-mcp.liplus.workers.dev"; const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; +const AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN || ""; + +/** Build common headers with optional Bearer auth */ +function authHeaders(extra) { + const h = { ...extra }; + if (AUTH_TOKEN) h["Authorization"] = `Bearer ${AUTH_TOKEN}`; + return h; +} // ── Remote MCP Session (lazy, reused) ──────────────────────────────────────── @@ -29,10 +37,10 @@ async function getSessionId() { const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: { + headers: authHeaders({ "Content-Type": "application/json", Accept: "application/json, text/event-stream", - }, + }), body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", @@ -54,11 +62,11 @@ async function callRemoteTool(name, args) { const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: { + headers: authHeaders({ "Content-Type": "application/json", Accept: "application/json, text/event-stream", "mcp-session-id": sessionId, - }, + }), body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", @@ -210,7 +218,10 @@ async function connectSSE() { return; } - const es = new EventSourceImpl(`${WORKER_URL}/events`); + const sseUrl = AUTH_TOKEN + ? `${WORKER_URL}/events?token=${encodeURIComponent(AUTH_TOKEN)}` + : `${WORKER_URL}/events`; + const es = new EventSourceImpl(sseUrl); es.onmessage = (event) => { try { diff --git a/worker/src/github-ip.ts b/worker/src/github-ip.ts new file mode 100644 index 0000000..8d8bab7 --- /dev/null +++ b/worker/src/github-ip.ts @@ -0,0 +1,145 @@ +/** + * GitHub IP allowlist for webhook endpoint protection. + * + * Fetches GitHub's webhook IP ranges from api.github.com/meta, + * caches them, and checks incoming requests against the allowlist. + * Falls back to hardcoded ranges if the API is unreachable. + */ + +// Hardcoded fallback — GitHub webhook IPs as of 2026-03. +// Update periodically or rely on the live fetch. +const FALLBACK_CIDRS = [ + "140.82.112.0/20", + "185.199.108.0/22", + "192.30.252.0/22", + "143.55.64.0/20", +]; + +const CACHE_KEY = "https://api.github.com/meta#hooks"; +const CACHE_TTL_SECONDS = 3600; // 1 hour + +/** In-memory cache for the current isolate lifetime */ +let memCache: { cidrs: string[]; expiresAt: number } | null = null; + +/** + * Parse an IPv4 CIDR and return [networkInt, maskInt]. + * Returns null for IPv6 or invalid input. + */ +function parseIPv4CIDR(cidr: string): [number, number] | null { + const match = cidr.match(/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/); + if (!match) return null; + const ip = match[1]; + const prefix = parseInt(match[2], 10); + if (prefix < 0 || prefix > 32) return null; + + const parts = ip.split(".").map(Number); + if (parts.some((p) => p < 0 || p > 255)) return null; + + const ipInt = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + return [ipInt >>> 0, mask]; +} + +/** Parse an IPv4 address to a 32-bit unsigned integer. Returns null for IPv6. */ +function parseIPv4(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + const nums = parts.map(Number); + if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null; + return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0; +} + +/** Check if an IPv4 address matches any of the given CIDRs. */ +function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean { + const ipInt = parseIPv4(ip); + if (ipInt === null) { + // IPv6 — check string prefix match against IPv6 CIDRs + // For now, allow IPv6 through (GitHub rarely sends webhooks via IPv6) + return true; + } + + for (const cidr of cidrs) { + const parsed = parseIPv4CIDR(cidr); + if (!parsed) continue; // skip IPv6 CIDRs + const [network, mask] = parsed; + if ((ipInt & mask) === (network & mask)) return true; + } + return false; +} + +/** + * Fetch GitHub webhook IP ranges, with Cache API + in-memory caching. + * Falls back to hardcoded ranges on failure. + */ +async function getGitHubHookCIDRs(): Promise { + // 1. Check in-memory cache + if (memCache && Date.now() < memCache.expiresAt) { + return memCache.cidrs; + } + + // 2. Check Cache API + try { + const cache = caches.default; + const cached = await cache.match(CACHE_KEY); + if (cached) { + const data = await cached.json() as { hooks?: string[] }; + if (data.hooks?.length) { + memCache = { cidrs: data.hooks, expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000 }; + return data.hooks; + } + } + } catch { + // Cache API may not be available in some environments + } + + // 3. Fetch from GitHub + try { + const res = await fetch("https://api.github.com/meta", { + headers: { "User-Agent": "github-webhook-mcp" }, + }); + if (res.ok) { + const data = await res.json() as { hooks?: string[] }; + if (data.hooks?.length) { + // Store in Cache API + try { + const cache = caches.default; + await cache.put( + CACHE_KEY, + new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "Cache-Control": `max-age=${CACHE_TTL_SECONDS}`, + }, + }), + ); + } catch { + // Cache write failure is non-fatal + } + + memCache = { cidrs: data.hooks, expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000 }; + return data.hooks; + } + } + } catch { + // Network failure — fall through to hardcoded + } + + // 4. Fallback + return FALLBACK_CIDRS; +} + +/** + * Check if the request comes from a GitHub webhook IP. + * Returns true if allowed, false if blocked. + * + * Skips the check when CF-Connecting-IP is absent (local dev). + */ +export async function isGitHubWebhookIP(request: Request): Promise { + const clientIP = request.headers.get("CF-Connecting-IP"); + + // In local dev (wrangler dev), CF-Connecting-IP may be absent — allow through + if (!clientIP) return true; + + const cidrs = await getGitHubHookCIDRs(); + return ipMatchesCIDRs(clientIP, cidrs); +} diff --git a/worker/src/index.ts b/worker/src/index.ts index 1e51570..bcf2fdf 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -8,6 +8,8 @@ */ import { WebhookMcpAgent } from "./agent.js"; import { WebhookStore } from "./store.js"; +import { isGitHubWebhookIP } from "./github-ip.js"; +import { checkWebhookRateLimit, checkApiRateLimit, rateLimitResponse } from "./rate-limit.js"; export { WebhookMcpAgent, WebhookStore }; @@ -15,6 +17,31 @@ interface Env { MCP_OBJECT: DurableObjectNamespace; WEBHOOK_STORE: DurableObjectNamespace; GITHUB_WEBHOOK_SECRET?: string; + MCP_AUTH_TOKEN?: string; +} + +/** + * Check Bearer token authentication. + * Returns a 401 Response if auth fails, or null if auth passes. + * Skips check when MCP_AUTH_TOKEN is not configured (backward compatible). + */ +function checkBearerAuth(request: Request, env: Env): Response | null { + if (!env.MCP_AUTH_TOKEN) return null; + + // Check Authorization header first + const auth = request.headers.get("Authorization") || ""; + if (auth.startsWith("Bearer ") && auth.slice(7) === env.MCP_AUTH_TOKEN) { + return null; + } + + // Fall back to ?token= query parameter (for WebSocket clients) + const url = new URL(request.url); + const tokenParam = url.searchParams.get("token"); + if (tokenParam === env.MCP_AUTH_TOKEN) { + return null; + } + + return new Response("Unauthorized", { status: 401 }); } async function verifyGitHubSignature( @@ -46,6 +73,15 @@ export default { // ── Webhook receiver ─────────────────────────────────── if (url.pathname === "/webhooks/github" && request.method === "POST") { + // IP allowlist — block non-GitHub IPs before any processing + if (!(await isGitHubWebhookIP(request))) { + return new Response("Forbidden", { status: 403 }); + } + + // Rate limit per IP + const webhookIP = request.headers.get("CF-Connecting-IP") || "unknown"; + if (!checkWebhookRateLimit(webhookIP)) return rateLimitResponse(); + const body = await request.text(); // Signature verification @@ -86,8 +122,15 @@ export default { ); } + // ── Rate limit for API endpoints ─────────────────────── + const apiIP = request.headers.get("CF-Connecting-IP") || "unknown"; + if (!checkApiRateLimit(apiIP)) return rateLimitResponse(); + // ── SSE stream (routed to WebhookStore DO) ───────────── if (url.pathname === "/events" && request.method === "GET") { + const authError = checkBearerAuth(request, env); + if (authError) return authError; + const doId = env.WEBHOOK_STORE.idFromName("singleton"); const stub = env.WEBHOOK_STORE.get(doId); return stub.fetch(request); @@ -95,6 +138,9 @@ export default { // ── MCP endpoint (delegate to McpAgent.serve handler) ── if (url.pathname.startsWith("/mcp")) { + const authError = checkBearerAuth(request, env); + if (authError) return authError; + return mcpHandler.fetch(request, env, ctx); } diff --git a/worker/src/rate-limit.ts b/worker/src/rate-limit.ts new file mode 100644 index 0000000..6c8d93e --- /dev/null +++ b/worker/src/rate-limit.ts @@ -0,0 +1,74 @@ +/** + * Simple in-memory sliding-window rate limiter. + * + * NOTE: This is per-isolate (not globally consistent across Workers instances). + * For precise global rate limiting, use Cloudflare Rate Limiting rules in the + * dashboard or a Durable Object-based counter. This provides a lightweight + * first line of defense. + */ + +interface Counter { + count: number; + resetAt: number; +} + +const counters = new Map(); + +const WINDOW_MS = 60_000; // 1 minute window +const MAX_WEBHOOK = 300; // webhook endpoint: 300 req/min per IP +const MAX_API = 120; // MCP/events endpoint: 120 req/min per IP + +// Periodic cleanup to prevent unbounded map growth +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL = 5 * 60_000; // 5 minutes + +function cleanup() { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL) return; + lastCleanup = now; + for (const [key, counter] of counters) { + if (now >= counter.resetAt) counters.delete(key); + } +} + +/** + * Check rate limit for a given key (typically IP + endpoint type). + * Returns true if the request is allowed, false if rate-limited. + */ +function check(key: string, max: number): boolean { + cleanup(); + + const now = Date.now(); + const existing = counters.get(key); + + if (!existing || now >= existing.resetAt) { + counters.set(key, { count: 1, resetAt: now + WINDOW_MS }); + return true; + } + + existing.count++; + return existing.count <= max; +} + +/** + * Check rate limit for webhook endpoint. + * Higher limit since GitHub can send bursts of webhooks. + */ +export function checkWebhookRateLimit(ip: string): boolean { + return check(`wh:${ip}`, MAX_WEBHOOK); +} + +/** + * Check rate limit for API endpoints (MCP, events). + */ +export function checkApiRateLimit(ip: string): boolean { + return check(`api:${ip}`, MAX_API); +} + +/** Build a 429 response with Retry-After header. */ +export function rateLimitResponse(): Response { + return new Response("Too Many Requests", { + status: 429, + headers: { "Retry-After": "60" }, + }); +}