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
21 changes: 1 addition & 20 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "./oauth.js";
import { isGitHubWebhookIP } from "./github-ip.js";
import { checkWebhookRateLimit, checkApiRateLimit, checkTenantQuota, rateLimitResponse } from "./rate-limit.js";
import { verifyGitHubSignature } from "./signature.js";

export { WebhookMcpAgent, WebhookStore, TenantRegistry };

Expand All @@ -39,26 +40,6 @@ interface Env extends OAuthEnv {
GITHUB_WEBHOOK_SECRET?: string;
}

async function verifyGitHubSignature(
secret: string,
body: string,
signature: string,
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
const expected = "sha256=" + Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return expected === signature;
}

/**
* Resolve installation_id to account_id via TenantRegistry DO.
* On installation.created, registers the mapping first.
Expand Down
29 changes: 29 additions & 0 deletions worker/src/signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* GitHub webhook signature verification (#227).
*
* Verifies GitHub's `X-Hub-Signature-256` header — an HMAC-SHA256 digest of the
* raw request body keyed with the shared webhook secret — using Web Crypto
* (`crypto.subtle`). Returns true only when the recomputed `sha256=<hex>` digest
* exactly matches the supplied signature string; any mismatch (tampered body,
* wrong secret, malformed signature) returns false. This is the webhook trust
* boundary: it proves both authentic GitHub origin and body integrity.
*/
export async function verifyGitHubSignature(
secret: string,
body: string,
signature: string,
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
const expected = "sha256=" + Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return expected === signature;
}
64 changes: 64 additions & 0 deletions worker/test/signature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Unit tests for worker/src/signature.ts (#227).
*
* verifyGitHubSignature is the webhook trust boundary: it must accept a body
* signed with the matching secret and reject every other case. The expected
* valid signature is generated INDEPENDENTLY via Node's node:crypto
* (createHmac → "sha256=" + hex digest), giving an oracle that does not call
* the function under test. crypto.subtle exists in Node 20+, so this runs as a
* pure tsx --test unit test (no Miniflare).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";

import { verifyGitHubSignature } from "../src/signature.js";

const SECRET = "s3cr3t-webhook-key";
const BODY = JSON.stringify({ action: "opened", number: 42 });

/** Independent oracle: GitHub's X-Hub-Signature-256 = "sha256=" + HMAC-SHA256 hex. */
function sign(secret: string, body: string): string {
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
}

// ── Valid case ───────────────────────────────────────────────────────

test("accepts a signature generated with the same secret and body", async () => {
const signature = sign(SECRET, BODY);
assert.equal(await verifyGitHubSignature(SECRET, BODY, signature), true);
});

// ── Tampered body ────────────────────────────────────────────────────

test("rejects when the body was tampered after signing", async () => {
// Sign body A, verify against body B.
const signature = sign(SECRET, BODY);
const tamperedBody = JSON.stringify({ action: "opened", number: 9999 });
assert.equal(await verifyGitHubSignature(SECRET, tamperedBody, signature), false);
});

// ── Wrong secret ─────────────────────────────────────────────────────

test("rejects when verified with a different secret", async () => {
const signature = sign(SECRET, BODY);
assert.equal(await verifyGitHubSignature("wrong-secret", BODY, signature), false);
});

// ── Malformed signature formats ──────────────────────────────────────

test("rejects an empty signature string", async () => {
assert.equal(await verifyGitHubSignature(SECRET, BODY, ""), false);
});

test("rejects a signature without the sha256= prefix", async () => {
// Same hex digest but missing the "sha256=" prefix.
const hexOnly = createHmac("sha256", SECRET).update(BODY).digest("hex");
assert.equal(await verifyGitHubSignature(SECRET, BODY, hexOnly), false);
});

test("rejects a sha256=-prefixed hex of the wrong length", async () => {
// Truncated digest (valid prefix, too few hex chars).
const shortHex = createHmac("sha256", SECRET).update(BODY).digest("hex").slice(0, 32);
assert.equal(await verifyGitHubSignature(SECRET, BODY, "sha256=" + shortHex), false);
});
Loading