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
8 changes: 7 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
25 changes: 25 additions & 0 deletions src/github/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Response> {
let payload: GitHubWebhookPayload;
try {
payload = JSON.parse(rawBody) as GitHubWebhookPayload;
Expand Down Expand Up @@ -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<Response> {
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);
Expand Down
19 changes: 19 additions & 0 deletions src/orb/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ export async function relaySignature(secret: string, body: string): Promise<stri
return [...new Uint8Array(sig)].map((b) => 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=<hex>` 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<boolean> {
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" };
Expand Down
49 changes: 48 additions & 1 deletion test/integration/orb-relay.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, string> = {}) => 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" });
});
});
Loading