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
19 changes: 14 additions & 5 deletions local-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Record<string, string> {
const h: Record<string, string> = { ...extra };
if (AUTH_TOKEN) h["Authorization"] = `Bearer ${AUTH_TOKEN}`;
return h;
}

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

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

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",
Expand All @@ -53,11 +61,11 @@ async function callRemoteTool(name: string, args: Record<string, unknown>): 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",
Expand Down Expand Up @@ -166,7 +174,8 @@ function connectWebSocket() {
let pingTimer: ReturnType<typeof setInterval> | 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
Expand Down
21 changes: 16 additions & 5 deletions mcp-server/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────────────────────────────

Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
145 changes: 145 additions & 0 deletions worker/src/github-ip.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
// 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<boolean> {
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);
}
46 changes: 46 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,40 @@
*/
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 };

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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -86,15 +122,25 @@ 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);
}

// ── 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);
}

Expand Down
Loading
Loading