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
11 changes: 11 additions & 0 deletions migrations/0069_orb_relay_registration.sql
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 20 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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/");
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 @@ -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";
Expand Down
40 changes: 40 additions & 0 deletions src/orb/relay.ts
Original file line number Diff line number Diff line change
@@ -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<RegisterResult> {
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 };
}
95 changes: 95 additions & 0 deletions test/integration/orb-relay.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | null> = {}) => {
const all: Record<string, string | number | null> = { 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<string> => {
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);
});
});
Loading