Skip to content

Commit 53121bc

Browse files
liplus-lin-layLin & Layclaude
authored
feat(worker): add bearer auth, GitHub IP allowlist, and rate limiting (#73)
セキュリティ基盤強化: マルチテナント公開サービス化 (#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: Lin & Lay <liplus.lin.lay@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 42e2c87 commit 53121bc

5 files changed

Lines changed: 295 additions & 10 deletions

File tree

local-mcp/src/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import WebSocket from "ws";
1818

1919
const WORKER_URL = process.env.WEBHOOK_WORKER_URL || "https://github-webhook-mcp.liplus.workers.dev";
2020
const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
21+
const AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN || "";
22+
23+
/** Build common headers with optional Bearer auth */
24+
function authHeaders(extra?: Record<string, string>): Record<string, string> {
25+
const h: Record<string, string> = { ...extra };
26+
if (AUTH_TOKEN) h["Authorization"] = `Bearer ${AUTH_TOKEN}`;
27+
return h;
28+
}
2129

2230
// ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
2331

@@ -28,10 +36,10 @@ async function getSessionId(): Promise<string> {
2836

2937
const res = await fetch(`${WORKER_URL}/mcp`, {
3038
method: "POST",
31-
headers: {
39+
headers: authHeaders({
3240
"Content-Type": "application/json",
3341
"Accept": "application/json, text/event-stream",
34-
},
42+
}),
3543
body: JSON.stringify({
3644
jsonrpc: "2.0",
3745
method: "initialize",
@@ -53,11 +61,11 @@ async function callRemoteTool(name: string, args: Record<string, unknown>): Prom
5361

5462
const res = await fetch(`${WORKER_URL}/mcp`, {
5563
method: "POST",
56-
headers: {
64+
headers: authHeaders({
5765
"Content-Type": "application/json",
5866
"Accept": "application/json, text/event-stream",
5967
"mcp-session-id": sessionId,
60-
},
68+
}),
6169
body: JSON.stringify({
6270
jsonrpc: "2.0",
6371
method: "tools/call",
@@ -166,7 +174,8 @@ function connectWebSocket() {
166174
let pingTimer: ReturnType<typeof setInterval> | null = null;
167175

168176
function connect() {
169-
ws = new WebSocket(wsUrl);
177+
const wsOptions = AUTH_TOKEN ? { headers: { "Authorization": `Bearer ${AUTH_TOKEN}` } } : undefined;
178+
ws = new WebSocket(wsUrl, wsOptions);
170179

171180
ws.on("open", () => {
172181
// Send periodic pings to keep connection alive

mcp-server/server/index.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const WORKER_URL =
1919
process.env.WEBHOOK_WORKER_URL ||
2020
"https://github-webhook-mcp.liplus.workers.dev";
2121
const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
22+
const AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN || "";
23+
24+
/** Build common headers with optional Bearer auth */
25+
function authHeaders(extra) {
26+
const h = { ...extra };
27+
if (AUTH_TOKEN) h["Authorization"] = `Bearer ${AUTH_TOKEN}`;
28+
return h;
29+
}
2230

2331
// ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
2432

@@ -29,10 +37,10 @@ async function getSessionId() {
2937

3038
const res = await fetch(`${WORKER_URL}/mcp`, {
3139
method: "POST",
32-
headers: {
40+
headers: authHeaders({
3341
"Content-Type": "application/json",
3442
Accept: "application/json, text/event-stream",
35-
},
43+
}),
3644
body: JSON.stringify({
3745
jsonrpc: "2.0",
3846
method: "initialize",
@@ -54,11 +62,11 @@ async function callRemoteTool(name, args) {
5462

5563
const res = await fetch(`${WORKER_URL}/mcp`, {
5664
method: "POST",
57-
headers: {
65+
headers: authHeaders({
5866
"Content-Type": "application/json",
5967
Accept: "application/json, text/event-stream",
6068
"mcp-session-id": sessionId,
61-
},
69+
}),
6270
body: JSON.stringify({
6371
jsonrpc: "2.0",
6472
method: "tools/call",
@@ -210,7 +218,10 @@ async function connectSSE() {
210218
return;
211219
}
212220

213-
const es = new EventSourceImpl(`${WORKER_URL}/events`);
221+
const sseUrl = AUTH_TOKEN
222+
? `${WORKER_URL}/events?token=${encodeURIComponent(AUTH_TOKEN)}`
223+
: `${WORKER_URL}/events`;
224+
const es = new EventSourceImpl(sseUrl);
214225

215226
es.onmessage = (event) => {
216227
try {

worker/src/github-ip.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* GitHub IP allowlist for webhook endpoint protection.
3+
*
4+
* Fetches GitHub's webhook IP ranges from api.github.com/meta,
5+
* caches them, and checks incoming requests against the allowlist.
6+
* Falls back to hardcoded ranges if the API is unreachable.
7+
*/
8+
9+
// Hardcoded fallback — GitHub webhook IPs as of 2026-03.
10+
// Update periodically or rely on the live fetch.
11+
const FALLBACK_CIDRS = [
12+
"140.82.112.0/20",
13+
"185.199.108.0/22",
14+
"192.30.252.0/22",
15+
"143.55.64.0/20",
16+
];
17+
18+
const CACHE_KEY = "https://api.github.com/meta#hooks";
19+
const CACHE_TTL_SECONDS = 3600; // 1 hour
20+
21+
/** In-memory cache for the current isolate lifetime */
22+
let memCache: { cidrs: string[]; expiresAt: number } | null = null;
23+
24+
/**
25+
* Parse an IPv4 CIDR and return [networkInt, maskInt].
26+
* Returns null for IPv6 or invalid input.
27+
*/
28+
function parseIPv4CIDR(cidr: string): [number, number] | null {
29+
const match = cidr.match(/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/);
30+
if (!match) return null;
31+
const ip = match[1];
32+
const prefix = parseInt(match[2], 10);
33+
if (prefix < 0 || prefix > 32) return null;
34+
35+
const parts = ip.split(".").map(Number);
36+
if (parts.some((p) => p < 0 || p > 255)) return null;
37+
38+
const ipInt = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
39+
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
40+
return [ipInt >>> 0, mask];
41+
}
42+
43+
/** Parse an IPv4 address to a 32-bit unsigned integer. Returns null for IPv6. */
44+
function parseIPv4(ip: string): number | null {
45+
const parts = ip.split(".");
46+
if (parts.length !== 4) return null;
47+
const nums = parts.map(Number);
48+
if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null;
49+
return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
50+
}
51+
52+
/** Check if an IPv4 address matches any of the given CIDRs. */
53+
function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean {
54+
const ipInt = parseIPv4(ip);
55+
if (ipInt === null) {
56+
// IPv6 — check string prefix match against IPv6 CIDRs
57+
// For now, allow IPv6 through (GitHub rarely sends webhooks via IPv6)
58+
return true;
59+
}
60+
61+
for (const cidr of cidrs) {
62+
const parsed = parseIPv4CIDR(cidr);
63+
if (!parsed) continue; // skip IPv6 CIDRs
64+
const [network, mask] = parsed;
65+
if ((ipInt & mask) === (network & mask)) return true;
66+
}
67+
return false;
68+
}
69+
70+
/**
71+
* Fetch GitHub webhook IP ranges, with Cache API + in-memory caching.
72+
* Falls back to hardcoded ranges on failure.
73+
*/
74+
async function getGitHubHookCIDRs(): Promise<string[]> {
75+
// 1. Check in-memory cache
76+
if (memCache && Date.now() < memCache.expiresAt) {
77+
return memCache.cidrs;
78+
}
79+
80+
// 2. Check Cache API
81+
try {
82+
const cache = caches.default;
83+
const cached = await cache.match(CACHE_KEY);
84+
if (cached) {
85+
const data = await cached.json() as { hooks?: string[] };
86+
if (data.hooks?.length) {
87+
memCache = { cidrs: data.hooks, expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000 };
88+
return data.hooks;
89+
}
90+
}
91+
} catch {
92+
// Cache API may not be available in some environments
93+
}
94+
95+
// 3. Fetch from GitHub
96+
try {
97+
const res = await fetch("https://api.github.com/meta", {
98+
headers: { "User-Agent": "github-webhook-mcp" },
99+
});
100+
if (res.ok) {
101+
const data = await res.json() as { hooks?: string[] };
102+
if (data.hooks?.length) {
103+
// Store in Cache API
104+
try {
105+
const cache = caches.default;
106+
await cache.put(
107+
CACHE_KEY,
108+
new Response(JSON.stringify(data), {
109+
headers: {
110+
"Content-Type": "application/json",
111+
"Cache-Control": `max-age=${CACHE_TTL_SECONDS}`,
112+
},
113+
}),
114+
);
115+
} catch {
116+
// Cache write failure is non-fatal
117+
}
118+
119+
memCache = { cidrs: data.hooks, expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000 };
120+
return data.hooks;
121+
}
122+
}
123+
} catch {
124+
// Network failure — fall through to hardcoded
125+
}
126+
127+
// 4. Fallback
128+
return FALLBACK_CIDRS;
129+
}
130+
131+
/**
132+
* Check if the request comes from a GitHub webhook IP.
133+
* Returns true if allowed, false if blocked.
134+
*
135+
* Skips the check when CF-Connecting-IP is absent (local dev).
136+
*/
137+
export async function isGitHubWebhookIP(request: Request): Promise<boolean> {
138+
const clientIP = request.headers.get("CF-Connecting-IP");
139+
140+
// In local dev (wrangler dev), CF-Connecting-IP may be absent — allow through
141+
if (!clientIP) return true;
142+
143+
const cidrs = await getGitHubHookCIDRs();
144+
return ipMatchesCIDRs(clientIP, cidrs);
145+
}

worker/src/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,40 @@
88
*/
99
import { WebhookMcpAgent } from "./agent.js";
1010
import { WebhookStore } from "./store.js";
11+
import { isGitHubWebhookIP } from "./github-ip.js";
12+
import { checkWebhookRateLimit, checkApiRateLimit, rateLimitResponse } from "./rate-limit.js";
1113

1214
export { WebhookMcpAgent, WebhookStore };
1315

1416
interface Env {
1517
MCP_OBJECT: DurableObjectNamespace;
1618
WEBHOOK_STORE: DurableObjectNamespace;
1719
GITHUB_WEBHOOK_SECRET?: string;
20+
MCP_AUTH_TOKEN?: string;
21+
}
22+
23+
/**
24+
* Check Bearer token authentication.
25+
* Returns a 401 Response if auth fails, or null if auth passes.
26+
* Skips check when MCP_AUTH_TOKEN is not configured (backward compatible).
27+
*/
28+
function checkBearerAuth(request: Request, env: Env): Response | null {
29+
if (!env.MCP_AUTH_TOKEN) return null;
30+
31+
// Check Authorization header first
32+
const auth = request.headers.get("Authorization") || "";
33+
if (auth.startsWith("Bearer ") && auth.slice(7) === env.MCP_AUTH_TOKEN) {
34+
return null;
35+
}
36+
37+
// Fall back to ?token= query parameter (for WebSocket clients)
38+
const url = new URL(request.url);
39+
const tokenParam = url.searchParams.get("token");
40+
if (tokenParam === env.MCP_AUTH_TOKEN) {
41+
return null;
42+
}
43+
44+
return new Response("Unauthorized", { status: 401 });
1845
}
1946

2047
async function verifyGitHubSignature(
@@ -46,6 +73,15 @@ export default {
4673

4774
// ── Webhook receiver ───────────────────────────────────
4875
if (url.pathname === "/webhooks/github" && request.method === "POST") {
76+
// IP allowlist — block non-GitHub IPs before any processing
77+
if (!(await isGitHubWebhookIP(request))) {
78+
return new Response("Forbidden", { status: 403 });
79+
}
80+
81+
// Rate limit per IP
82+
const webhookIP = request.headers.get("CF-Connecting-IP") || "unknown";
83+
if (!checkWebhookRateLimit(webhookIP)) return rateLimitResponse();
84+
4985
const body = await request.text();
5086

5187
// Signature verification
@@ -86,15 +122,25 @@ export default {
86122
);
87123
}
88124

125+
// ── Rate limit for API endpoints ───────────────────────
126+
const apiIP = request.headers.get("CF-Connecting-IP") || "unknown";
127+
if (!checkApiRateLimit(apiIP)) return rateLimitResponse();
128+
89129
// ── SSE stream (routed to WebhookStore DO) ─────────────
90130
if (url.pathname === "/events" && request.method === "GET") {
131+
const authError = checkBearerAuth(request, env);
132+
if (authError) return authError;
133+
91134
const doId = env.WEBHOOK_STORE.idFromName("singleton");
92135
const stub = env.WEBHOOK_STORE.get(doId);
93136
return stub.fetch(request);
94137
}
95138

96139
// ── MCP endpoint (delegate to McpAgent.serve handler) ──
97140
if (url.pathname.startsWith("/mcp")) {
141+
const authError = checkBearerAuth(request, env);
142+
if (authError) return authError;
143+
98144
return mcpHandler.fetch(request, env, ctx);
99145
}
100146

0 commit comments

Comments
 (0)