diff --git a/src/api/routes.ts b/src/api/routes.ts index b27e393fdf..7f7ffe9833 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -121,7 +121,7 @@ import { sanitizePublicComment, type GittensoryMentionCommandName, } from "../github/commands"; -import { handleGitHubWebhook } from "../github/webhook"; +import { handleGitHubWebhook, handleOrbRelay } from "../github/webhook"; import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleOrbWebhook } from "../orb/webhook"; import { handleOrbOAuthCallback } from "../orb/oauth"; @@ -2868,6 +2868,11 @@ export function createApp() { app.post("/v1/github/webhook", handleGitHubWebhook); + // Brokered self-host relay RECEIVER (#1255) — the central Orb forwards this container's repos' events here, + // HMAC-signed with the container's enrollment secret. Verified against ORB_ENROLLMENT_SECRET, then enqueued + // like a GitHub webhook. Auth IS the relay signature (token-exempt); 404 when not a brokered self-host. + app.post("/v1/orb/relay", handleOrbRelay); + // Gittensory Orb central GitHub App (#1255) — inbound webhook for the ONE shared Orb App maintainers install. // Verifies the Orb App's OWN webhook secret, dedups, and records install + PR/review events (the homepage // fleet-metrics data spine). Separate App + secret from the review-app /v1/github/webhook above. @@ -4952,6 +4957,7 @@ function requiresApiToken(path: string): boolean { if (path.startsWith("/v1/auth/")) return false; if (path === "/v1/github/webhook") return false; if (path === "/v1/orb/webhook") return false; + if (path === "/v1/orb/relay") return false; if (path === "/v1/orb/oauth/callback") return false; if (path === "/v1/orb/token") return false; if (path === "/v1/orb/relay/register") return false; diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 53b95b33f9..bdd286342a 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -99,6 +99,7 @@ export function routeClassForPath(path: string): RateLimitClass { // Orb central-App inbound webhook — same class as the review-app webhook above (GitHub delivers from a // narrow IP range; the per-IP strict cap is proven for /v1/github/webhook and #1292 reserves headroom). if (path === "/v1/orb/webhook") return "strict"; + if (path === "/v1/orb/relay") return "strict"; if (path === "/v1/orb/oauth/callback") return "strict"; if (path === "/v1/orb/token") return "strict"; if (path === "/v1/orb/relay/register") return "strict"; diff --git a/src/github/webhook.ts b/src/github/webhook.ts index eedde22802..817e402edc 100644 --- a/src/github/webhook.ts +++ b/src/github/webhook.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { getWebhookEvent, recordWebhookEvent } from "../db/repositories"; import type { GitHubWebhookPayload, JobMessage } from "../types"; import { sha256Hex, verifyGitHubSignature } from "../utils/crypto"; +import { relayVerify } from "../orb/relay"; const DEFAULT_MAX_WEBHOOK_BODY_BYTES = 1024 * 1024; @@ -27,7 +28,13 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis if (!verified) { return c.json({ error: "invalid_signature" }, 401); } + return enqueueVerifiedWebhook(c, deliveryId, eventName, rawBody); +} +/** Shared post-verification path: parse → dedup → record → enqueue to the WEBHOOKS lane → 202. Used by the GitHub + * webhook receiver above AND the Orb relay receiver below (they verify the body differently — GitHub's HMAC vs the + * Orb relay HMAC — then share everything after). */ +export async function enqueueVerifiedWebhook(c: Context<{ Bindings: Env }>, deliveryId: string, eventName: string, rawBody: string): Promise { let payload: GitHubWebhookPayload; try { payload = JSON.parse(rawBody) as GitHubWebhookPayload; @@ -84,6 +91,24 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis return c.json({ ok: true, deliveryId, eventName, status: "queued" }, 202); } +/** The brokered self-host's relay RECEIVER. The central Orb forwards an event here, HMAC-signed (x-orb-signature- + * 256) with THIS container's enrollment secret. We verify with our own ORB_ENROLLMENT_SECRET, then enqueue the + * event exactly like a GitHub webhook (the body IS a GitHub webhook payload; only the transport differs). */ +export async function handleOrbRelay(c: Context<{ Bindings: Env }>): Promise { + const deliveryId = c.req.header("x-github-delivery") ?? null; + const eventName = c.req.header("x-github-event") ?? null; + if (!deliveryId || !eventName) return c.json({ error: "missing_github_headers" }, 400); + const secret = c.env.ORB_ENROLLMENT_SECRET; + if (!secret) return c.json({ error: "relay_not_configured" }, 404); // not a brokered self-host → no relay + const maxBodyBytes = parsePositiveInt(c.env.GITHUB_WEBHOOK_MAX_BODY_BYTES) ?? DEFAULT_MAX_WEBHOOK_BODY_BYTES; + const rawBody = await readBodyWithLimit(c.req.raw, maxBodyBytes); + if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: maxBodyBytes }, 413); + if (!(await relayVerify(secret, rawBody, c.req.header("x-orb-signature-256") ?? null))) { + return c.json({ error: "invalid_signature" }, 401); + } + return enqueueVerifiedWebhook(c, deliveryId, eventName, rawBody); +} + function parsePositiveInt(value: string | null | undefined): number | null { if (!value) return null; const parsed = Number.parseInt(value, 10); diff --git a/src/orb/relay.ts b/src/orb/relay.ts index d6ed854999..881ceaae15 100644 --- a/src/orb/relay.ts +++ b/src/orb/relay.ts @@ -29,6 +29,25 @@ export async function relaySignature(secret: string, body: string): Promise b.toString(16).padStart(2, "0")).join(""); } +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return bytes; +} + +/** Verify a relay signature (the `sha256=` value of x-orb-signature-256) over the body with `secret`, in + * CONSTANT TIME (crypto.subtle.verify). The container's relay receiver uses this with its ORB_ENROLLMENT_SECRET, + * so only the genuine Orb (which holds the encrypted copy of that secret) can drive it. */ +export async function relayVerify(secret: string, body: string, header: string | null): Promise { + if (!secret || !header) return false; + const hex = header.startsWith("sha256=") ? header.slice(7) : header; + const sigBytes = hexToBytes(hex); + if (!sigBytes) return false; + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); + return crypto.subtle.verify("HMAC", key, sigBytes, new TextEncoder().encode(body)); +} + export type RegisterResult = | { ok: true; installationId: number } | { error: "invalid_enrollment" | "installation_not_eligible" | "invalid_relay_url" | "encryption_unavailable" }; diff --git a/test/integration/orb-relay.test.ts b/test/integration/orb-relay.test.ts index d0de9b19cd..ec7a2e267d 100644 --- a/test/integration/orb-relay.test.ts +++ b/test/integration/orb-relay.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; import { issueOrbEnrollment } from "../../src/orb/broker"; -import { forwardOrbEvent, registerOrbRelay, relaySignature } from "../../src/orb/relay"; +import { forwardOrbEvent, registerOrbRelay, relaySignature, relayVerify } from "../../src/orb/relay"; import { createTestEnv, type TestD1Database } from "../helpers/d1"; const db = (e: Env) => e.DB as unknown as TestD1Database; @@ -151,3 +151,50 @@ describe("forwardOrbEvent", () => { expect(await forwardOrbEvent(noKey, { eventName: "pull_request", installationId: 803, deliveryId: "d", rawBody: "{}" })).toBe("skipped"); }); }); + +describe("relayVerify", () => { + it("accepts a valid signature (sha256= or bare hex) and rejects wrong-secret / malformed / missing", async () => { + const body = '{"x":1}'; + const sig = await relaySignature("s", body); + expect(await relayVerify("s", body, `sha256=${sig}`)).toBe(true); + expect(await relayVerify("s", body, sig)).toBe(true); // bare hex tolerated + expect(await relayVerify("s", body, `sha256=${await relaySignature("other", body)}`)).toBe(false); // wrong secret + expect(await relayVerify("s", body, "sha256=zz")).toBe(false); // non-hex + expect(await relayVerify("s", body, "sha256=abc")).toBe(false); // odd-length hex + expect(await relayVerify("", body, `sha256=${sig}`)).toBe(false); // no secret + expect(await relayVerify("s", body, null)).toBe(false); // no header + }); +}); + +describe("POST /v1/orb/relay (brokered self-host receiver)", () => { + const app = createApp(); + const CSECRET = "orbsec_container_abcdef"; + const containerEnv = (over: Record = {}) => createTestEnv({ ORB_ENROLLMENT_SECRET: CSECRET, ...over }); + const sign = async (body: string) => `sha256=${await relaySignature(CSECRET, body)}`; + const PR_BODY = JSON.stringify({ action: "opened", installation: { id: 5 }, repository: { full_name: "acme/app" } }); + + it("404 when this instance is not a brokered self-host (no ORB_ENROLLMENT_SECRET)", async () => { + expect((await app.request("/v1/orb/relay", { method: "POST", headers: { "x-github-event": "pull_request", "x-github-delivery": "d" }, body: PR_BODY }, createTestEnv())).status).toBe(404); + }); + + it("400 without the GitHub headers", async () => { + expect((await app.request("/v1/orb/relay", { method: "POST", body: PR_BODY }, containerEnv())).status).toBe(400); + }); + + it("401 on a missing or wrong-secret signature", async () => { + const h = { "x-github-event": "pull_request", "x-github-delivery": "d1" }; + expect((await app.request("/v1/orb/relay", { method: "POST", headers: h, body: PR_BODY }, containerEnv())).status).toBe(401); // no signature + expect((await app.request("/v1/orb/relay", { method: "POST", headers: { ...h, "x-orb-signature-256": "sha256=deadbeef" }, body: PR_BODY }, containerEnv())).status).toBe(401); + }); + + it("413 when the body exceeds the configured max", async () => { + const res = await app.request("/v1/orb/relay", { method: "POST", headers: { "x-github-event": "pull_request", "x-github-delivery": "d1", "x-orb-signature-256": await sign("x".repeat(50)) }, body: "x".repeat(50) }, containerEnv({ GITHUB_WEBHOOK_MAX_BODY_BYTES: "5" })); + expect(res.status).toBe(413); + }); + + it("202 + ENQUEUES on a valid Orb signature (the relayed event becomes a normal webhook job)", async () => { + const res = await app.request("/v1/orb/relay", { method: "POST", headers: { "x-github-event": "pull_request", "x-github-delivery": "rel-1", "x-orb-signature-256": await sign(PR_BODY) }, body: PR_BODY }, containerEnv()); + expect(res.status).toBe(202); + expect(await res.json()).toMatchObject({ status: "queued", deliveryId: "rel-1", eventName: "pull_request" }); + }); +});