diff --git a/migrations/0069_orb_relay_registration.sql b/migrations/0069_orb_relay_registration.sql new file mode 100644 index 0000000000..acfffd1a40 --- /dev/null +++ b/migrations/0069_orb_relay_registration.sql @@ -0,0 +1,11 @@ +-- Orb event RELAY (#1255): a brokered self-host registers its public relay URL so the central Orb can FORWARD its +-- repos' webhook events to the container (which reviews + acts via brokered tokens). The container's enrollment +-- secret is stored ENCRYPTED here (AES-256-GCM via TOKEN_ENCRYPTION_SECRET) so the Orb can HMAC-sign each forwarded +-- event with it; the container verifies the signature with its own ORB_ENROLLMENT_SECRET. Per-enrollment isolation +-- (a leak of one container's secret never lets it forge to another), and a DB-only leak can't forge (the encryption +-- key is a separate secret). The relay URL is SSRF-validated (https, public host) before it's ever forwarded to. +ALTER TABLE orb_enrollments ADD COLUMN relay_url TEXT; +ALTER TABLE orb_enrollments ADD COLUMN relay_secret_enc TEXT; +ALTER TABLE orb_enrollments ADD COLUMN relay_secret_iv TEXT; +ALTER TABLE orb_enrollments ADD COLUMN relay_secret_salt TEXT; +ALTER TABLE orb_enrollments ADD COLUMN relay_registered_at TEXT; diff --git a/src/api/routes.ts b/src/api/routes.ts index f0e8d49357..b27e393fdf 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -126,6 +126,7 @@ import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleOrbWebhook } from "../orb/webhook"; import { handleOrbOAuthCallback } from "../orb/oauth"; import { brokerOrbToken, isOrbBrokerEnabled, issueOrbEnrollment } from "../orb/broker"; +import { registerOrbRelay } from "../orb/relay"; import { computeFleetAnalytics } from "../orb/analytics"; import { handleMcpRequest } from "../mcp/server"; import { buildOpenApiSpec } from "../openapi/spec"; @@ -2887,6 +2888,24 @@ export function createApp() { return c.json(result); }); + // Orb event relay (#1255) — a brokered self-host registers its public relay URL so the Orb can forward its + // repos' events to it. Auth: the container's own enrollment secret (Bearer). Flag-gated (404 until enabled). + app.post("/v1/orb/relay/register", async (c) => { + if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404); + const auth = c.req.header("authorization") ?? ""; + const secret = auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""; + if (!secret) return c.json({ error: "missing_enrollment_secret" }, 401); + const body = (await c.req.json().catch(() => null)) as { relayUrl?: unknown } | null; + const relayUrl = typeof body?.relayUrl === "string" ? body.relayUrl.trim() : ""; + if (!relayUrl) return c.json({ error: "missing_relay_url" }, 400); + const result = await registerOrbRelay(c.env, secret, relayUrl); + if ("error" in result) { + const status = result.error === "invalid_enrollment" ? 401 : result.error === "installation_not_eligible" ? 403 : result.error === "encryption_unavailable" ? 500 : 400; + return c.json(result, status); + } + return c.json(result); + }); + // Gittensory Orb (#1255) — central fleet-calibration collector. Receives anonymized, reversal-aware // outcome batches from self-hosted instances. No auth required: all data is HMAC-anonymized by the sender; // dedup is enforced via UNIQUE(instance_id, repo_hash, pr_hash) in orb_signals. Rate-limited (strict, #1254). @@ -4935,6 +4954,7 @@ function requiresApiToken(path: string): boolean { if (path === "/v1/orb/webhook") 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; if (path === "/v1/orb/ingest") return false; if (path.startsWith("/v1/internal/")) return false; return path.startsWith("/v1/"); diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index ddcdea4c6b..53b95b33f9 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -101,6 +101,7 @@ export function routeClassForPath(path: string): RateLimitClass { if (path === "/v1/orb/webhook") 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"; // Orb telemetry ingest: unauthenticated + write, accepting anonymized batches from untrusted // self-host instances. Strict (10/min per IP) caps abuse — legitimate instances export hourly. if (path === "/v1/orb/ingest") return "strict"; diff --git a/src/orb/relay.ts b/src/orb/relay.ts new file mode 100644 index 0000000000..b0c78fb176 --- /dev/null +++ b/src/orb/relay.ts @@ -0,0 +1,40 @@ +// Orb event RELAY (#1255) — registration side. A brokered self-host registers its public relay URL so the central +// Orb can FORWARD its repos' webhook events to the container (which reviews + acts via brokered tokens). The +// container's enrollment secret is stored ENCRYPTED here (AES-256-GCM via TOKEN_ENCRYPTION_SECRET) so the Orb can +// HMAC-sign each forwarded event with it; the container verifies the signature with its own ORB_ENROLLMENT_SECRET. +// Per-enrollment isolation (one container's secret can never forge to another), and a DB-only leak can't forge +// (the encryption key is a separate secret). +import { hashToken } from "../auth/security"; +import { isSafeHttpUrl } from "../review/content-lane/safe-url"; +import { encryptSecret } from "../utils/crypto"; + +export type RegisterResult = + | { ok: true; installationId: number } + | { error: "invalid_enrollment" | "installation_not_eligible" | "invalid_relay_url" | "encryption_unavailable" }; + +/** Register (or update) the container's relay target for a valid enrollment. Validates the secret (→ the bound, + * registered, non-suspended install — same gate as the token broker), SSRF-validates the relay URL, then stores + * the URL + the enrollment secret encrypted at rest (for the forward-time HMAC). The container presents its OWN + * plaintext enrollment secret as the Bearer, so this is self-service + bound to that install. */ +export async function registerOrbRelay(env: Env, secret: string, relayUrl: string): Promise { + const row = await env.DB + .prepare("SELECT enroll_id, installation_id, state, revoked_at FROM orb_enrollments WHERE secret_hash = ?") + .bind(await hashToken(secret)) + .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null }>(); + if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" }; + const install = await env.DB + .prepare("SELECT registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") + .bind(row.installation_id) + .first<{ registered: number; suspended_at: string | null; removed_at: string | null }>(); + if (!install || install.registered !== 1 || install.suspended_at !== null || install.removed_at !== null) return { error: "installation_not_eligible" }; + // SSRF guard: the Orb will POST events to this URL — it must be a public https endpoint (no loopback / private / + // link-local host), so a registered relay URL can never coerce the Orb into hitting an internal service. + if (!isSafeHttpUrl(relayUrl)) return { error: "invalid_relay_url" }; + if (!env.TOKEN_ENCRYPTION_SECRET) return { error: "encryption_unavailable" }; + const enc = await encryptSecret(secret, env.TOKEN_ENCRYPTION_SECRET); + await env.DB + .prepare("UPDATE orb_enrollments SET relay_url = ?, relay_secret_enc = ?, relay_secret_iv = ?, relay_secret_salt = ?, relay_registered_at = CURRENT_TIMESTAMP WHERE enroll_id = ?") + .bind(relayUrl, enc.ciphertext, enc.iv, enc.salt, row.enroll_id) + .run(); + return { ok: true, installationId: row.installation_id }; +} diff --git a/test/integration/orb-relay.test.ts b/test/integration/orb-relay.test.ts new file mode 100644 index 0000000000..dd81421690 --- /dev/null +++ b/test/integration/orb-relay.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { issueOrbEnrollment } from "../../src/orb/broker"; +import { registerOrbRelay } from "../../src/orb/relay"; +import { createTestEnv, type TestD1Database } from "../helpers/d1"; + +const db = (e: Env) => e.DB as unknown as TestD1Database; +const seedInstall = (e: Env, id: number, cols: Record = {}) => { + const all: Record = { installation_id: id, registered: 1, ...cols }; + const keys = Object.keys(all); + return db(e).prepare(`INSERT INTO orb_github_installations (${keys.join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`).bind(...keys.map((k) => all[k] as string | number | null)).run(); +}; +const brokeredEnv = () => createTestEnv({ ORB_BROKER_ENABLED: "true", TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); +const enroll = async (e: Env, id: number): Promise => { + await seedInstall(e, id); + return ((await issueOrbEnrollment(e, id)) as { secret: string }).secret; +}; + +describe("registerOrbRelay", () => { + it("stores the relay URL + the ENCRYPTED secret for a valid enrollment", async () => { + const e = brokeredEnv(); + const secret = await enroll(e, 700); + expect(await registerOrbRelay(e, secret, "https://my-host.example/v1/orb/relay")).toEqual({ ok: true, installationId: 700 }); + const row = await db(e).prepare("SELECT relay_url, relay_secret_enc, relay_secret_iv FROM orb_enrollments WHERE installation_id=700").first<{ relay_url: string; relay_secret_enc: string; relay_secret_iv: string }>(); + expect(row?.relay_url).toBe("https://my-host.example/v1/orb/relay"); + expect(row?.relay_secret_enc).toBeTruthy(); + expect(row?.relay_secret_iv).toBeTruthy(); + expect(row?.relay_secret_enc).not.toContain(secret); // stored encrypted, never plaintext + }); + + it("rejects an unknown / revoked enrollment secret", async () => { + expect(await registerOrbRelay(brokeredEnv(), "orbsec_bogus", "https://x.example")).toEqual({ error: "invalid_enrollment" }); + }); + + it("rejects an ineligible install — unregistered, suspended, removed, or deleted", async () => { + const e = brokeredEnv(); + const s1 = await enroll(e, 701); + await db(e).prepare("UPDATE orb_github_installations SET registered=0 WHERE installation_id=701").run(); + expect(await registerOrbRelay(e, s1, "https://x.example")).toEqual({ error: "installation_not_eligible" }); // registered!=1 + const s2 = await enroll(e, 702); + await db(e).prepare("UPDATE orb_github_installations SET suspended_at=CURRENT_TIMESTAMP WHERE installation_id=702").run(); + expect(await registerOrbRelay(e, s2, "https://x.example")).toEqual({ error: "installation_not_eligible" }); // suspended + const s3 = await enroll(e, 703); + await db(e).prepare("UPDATE orb_github_installations SET removed_at=CURRENT_TIMESTAMP WHERE installation_id=703").run(); + expect(await registerOrbRelay(e, s3, "https://x.example")).toEqual({ error: "installation_not_eligible" }); // removed + const s4 = await enroll(e, 704); + await db(e).prepare("DELETE FROM orb_github_installations WHERE installation_id=704").run(); + expect(await registerOrbRelay(e, s4, "https://x.example")).toEqual({ error: "installation_not_eligible" }); // !install + }); + + it("SSRF-rejects a loopback / private / non-https relay URL", async () => { + const e = brokeredEnv(); + const secret = await enroll(e, 705); + expect(await registerOrbRelay(e, secret, "http://127.0.0.1/relay")).toEqual({ error: "invalid_relay_url" }); + expect(await registerOrbRelay(e, secret, "https://localhost/relay")).toEqual({ error: "invalid_relay_url" }); + }); + + it("errors when the server's encryption secret is unavailable", async () => { + const e = createTestEnv({ ORB_BROKER_ENABLED: "true" }); // no TOKEN_ENCRYPTION_SECRET + const secret = await enroll(e, 706); + expect(await registerOrbRelay(e, secret, "https://x.example/relay")).toEqual({ error: "encryption_unavailable" }); + }); +}); + +describe("POST /v1/orb/relay/register", () => { + const app = createApp(); + + it("404s when the broker flag is off (byte-identical deploy)", async () => { + expect((await app.request("/v1/orb/relay/register", { method: "POST" }, createTestEnv())).status).toBe(404); + }); + + it("401 without a secret, 400 without a relayUrl, 200 on success", async () => { + const e = brokeredEnv(); + const secret = await enroll(e, 710); + expect((await app.request("/v1/orb/relay/register", { method: "POST" }, e)).status).toBe(401); + expect((await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: `Bearer ${secret}` }, body: "{bad" }, e)).status).toBe(400); // unparseable body → catch → null → 400 + const ok = await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: `Bearer ${secret}` }, body: JSON.stringify({ relayUrl: "https://my-host.example/v1/orb/relay" }) }, e); + expect(ok.status).toBe(200); + expect(await ok.json()).toMatchObject({ ok: true, installationId: 710 }); + }); + + it("maps each failure to its status: 401 bad secret, 403 ineligible, 400 SSRF, 500 no-encryption", async () => { + const e = brokeredEnv(); + const sBad = "Bearer orbsec_bad"; + expect((await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: sBad }, body: JSON.stringify({ relayUrl: "https://x.example" }) }, e)).status).toBe(401); + const s1 = await enroll(e, 711); + expect((await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: `Bearer ${s1}` }, body: JSON.stringify({ relayUrl: "http://127.0.0.1" }) }, e)).status).toBe(400); + const s2 = await enroll(e, 712); + await db(e).prepare("UPDATE orb_github_installations SET registered=0 WHERE installation_id=712").run(); + expect((await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: `Bearer ${s2}` }, body: JSON.stringify({ relayUrl: "https://x.example" }) }, e)).status).toBe(403); + const noEnc = createTestEnv({ ORB_BROKER_ENABLED: "true" }); + const s3 = await enroll(noEnc, 713); + expect((await app.request("/v1/orb/relay/register", { method: "POST", headers: { authorization: `Bearer ${s3}` }, body: JSON.stringify({ relayUrl: "https://x.example/relay" }) }, noEnc)).status).toBe(500); + }); +});