Skip to content

Commit 51c28fa

Browse files
authored
fix(orb): authenticate fleet telemetry ingest (#1285)
* fix(orb): authenticate fleet telemetry ingest * fix(orb): constant-time compare the ingest token (timingSafeEqual)
1 parent 43b0d6c commit 51c28fa

6 files changed

Lines changed: 64 additions & 16 deletions

File tree

src/api/routes.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
extractCookieValue,
2222
isAuthorizedGitHubSessionLogin,
2323
revokeSession,
24+
timingSafeEqual,
2425
type AuthIdentity,
2526
} from "../auth/security";
2627
import { normalizeGittBountySnapshot } from "../bounties/ingest";
@@ -2914,12 +2915,13 @@ export function createApp() {
29142915
return c.json(result);
29152916
});
29162917

2917-
// Gittensory Orb (#1255) — central fleet-calibration collector. Receives anonymized, reversal-aware
2918-
// outcome batches from self-hosted instances. No auth required: all data is HMAC-anonymized by the sender;
2919-
// dedup is enforced via UNIQUE(instance_id, repo_hash, pr_hash) in orb_signals. Rate-limited (strict, #1254).
2918+
// Gittensory Orb (#1255) — central fleet-calibration collector. Receives anonymized, reversal-aware outcome
2919+
// batches from self-hosted instances. Sender-side HMAC anonymization is for privacy, not authentication.
2920+
// OPTIONAL shared-token gate (#1285): unset ⇒ OPEN ingress (the live fleet keeps working, as before); set
2921+
// ⇒ the collector REQUIRES it, so an operator can lock the write path down after distributing the matching
2922+
// ORB_COLLECTOR_TOKEN to exporters. Bounded by a hard body ceiling, and dedup'd via UNIQUE(instance_id, repo_hash, pr_hash).
29202923
app.post("/v1/orb/ingest", async (c) => {
2921-
// Open ingress (no shared secret — the fleet topology has no per-instance key the collector could
2922-
// verify), bounded by a hard body ceiling so it can't be used to make us buffer unbounded input.
2924+
if (!(await isAuthorizedOrbIngest(c.env, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
29232925
const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length"));
29242926
if (body === null) return c.json({ error: "payload_too_large" }, 413);
29252927
if (!body) return c.json({ error: "invalid_request" }, 400);
@@ -4945,6 +4947,18 @@ function toIsoQueryDate(value: string): string | undefined {
49454947
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
49464948
}
49474949

4950+
4951+
// Optional Orb-ingest auth (#1285). FAIL-OPEN by default: with no ORB_INGEST_TOKEN configured the ingress stays
4952+
// OPEN (matching today's live fleet — deploying this is non-breaking). Once the operator sets the token, the
4953+
// collector REQUIRES an exact bearer match, so the write path can be locked down after the matching
4954+
// ORB_COLLECTOR_TOKEN is rolled out to exporters.
4955+
async function isAuthorizedOrbIngest(env: Env, token: string | undefined): Promise<boolean> {
4956+
if (!env.ORB_INGEST_TOKEN) return true;
4957+
// Constant-time compare (mirrors every other secret check in auth/security) — a `===` here is timing-attack
4958+
// vulnerable for a shared secret.
4959+
return timingSafeEqual(token, env.ORB_INGEST_TOKEN);
4960+
}
4961+
49484962
function requiresApiToken(path: string): boolean {
49494963
if (path === "/health") return false;
49504964
if (path === "/v1/mcp/compatibility") return false;

src/env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ declare global {
103103
GITTENSORY_API_TOKEN: string;
104104
GITTENSORY_MCP_TOKEN: string;
105105
INTERNAL_JOB_TOKEN: string;
106+
/** Shared bearer secret required by the hosted Orb ingest collector. */
107+
ORB_INGEST_TOKEN?: string;
106108
/** AES-256-GCM master secret for maintainer BYOK provider keys (encrypt/decrypt at rest). A Worker
107109
* secret (`wrangler secret put`), never a public var. When absent, BYOK is unavailable and the AI
108110
* review silently falls back to free Workers AI. */

src/orb/ingest.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
// aggregate calibration metadata (verdict, outcome, reversal, bucketed reason, cycle time).
55

66
const MAX_BATCH = 500;
7+
const MAX_INSTANCE_ID_CHARS = 64;
8+
const MAX_HASH_CHARS = 128;
9+
const MAX_BUCKET_CHARS = 64;
710
const VALID_OUTCOMES = new Set(["merged", "closed"]);
811
const VALID_REVERSALS = new Set(["none", "reopened", "reverted"]);
912
const MIN_CYCLE_MS = 1_000; // <1s is implausible
@@ -87,7 +90,7 @@ export async function handleOrbIngest(body: string, db: D1Database): Promise<Orb
8790
}
8891

8992
const { instance_id, events } = payload as OrbIngestPayload;
90-
if (!instance_id || events.length === 0) {
93+
if (!instance_id || instance_id.length > MAX_INSTANCE_ID_CHARS || events.length === 0) {
9194
return { error: "invalid_payload" };
9295
}
9396

@@ -109,8 +112,8 @@ export async function handleOrbIngest(body: string, db: D1Database): Promise<Orb
109112

110113
for (const event of batch) {
111114
if (
112-
typeof event.repo_hash !== "string" || !event.repo_hash ||
113-
typeof event.pr_hash !== "string" || !event.pr_hash ||
115+
typeof event.repo_hash !== "string" || !event.repo_hash || event.repo_hash.length > MAX_HASH_CHARS ||
116+
typeof event.pr_hash !== "string" || !event.pr_hash || event.pr_hash.length > MAX_HASH_CHARS ||
114117
!VALID_OUTCOMES.has(event.outcome)
115118
) {
116119
continue;
@@ -136,7 +139,7 @@ export async function handleOrbIngest(body: string, db: D1Database): Promise<Orb
136139
typeof event.gate_verdict === "string" ? event.gate_verdict : null,
137140
event.outcome,
138141
reversal,
139-
typeof event.gate_reasoncode_bucket === "string" ? event.gate_reasoncode_bucket : null,
142+
typeof event.gate_reasoncode_bucket === "string" && event.gate_reasoncode_bucket.length <= MAX_BUCKET_CHARS ? event.gate_reasoncode_bucket : null,
140143
clampCycleMs(event.time_to_close_ms),
141144
typeof event.decision_timestamp === "string" ? event.decision_timestamp : null,
142145
typeof event.outcome_timestamp === "string" ? event.outcome_timestamp : null,

src/selfhost/orb-collector.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
1111
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
1212
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
13+
// ORB_COLLECTOR_TOKEN=<secret> — bearer credential for the hosted collector
1314
//
1415
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
1516
// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
@@ -195,11 +196,17 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
195196

196197
const body = JSON.stringify(payload);
197198
const signature = createHmac("sha256", secret).update(body).digest("hex");
199+
const collectorToken = process.env.ORB_COLLECTOR_TOKEN;
198200

199201
try {
200202
const res = await fetchFn(collectorUrl, {
201203
method: "POST",
202-
headers: { "content-type": "application/json", "x-orb-signature": `sha256=${signature}`, "x-orb-instance": instance },
204+
headers: {
205+
"content-type": "application/json",
206+
"x-orb-signature": `sha256=${signature}`,
207+
"x-orb-instance": instance,
208+
...(collectorToken ? { authorization: `Bearer ${collectorToken}` } : {}),
209+
},
203210
body,
204211
});
205212
if (!res.ok) {

test/integration/orb-ingest.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,22 @@ describe("handleOrbIngest()", () => {
2020
expect(await handleOrbIngest("{not json}", makeDb())).toEqual({ error: "invalid_json" });
2121
});
2222

23-
it("returns invalid_payload: instance_id not a string / events not an array / empty instance / empty events", async () => {
23+
it("returns invalid_payload: instance_id not a string / events not an array / empty/oversized instance / empty events", async () => {
2424
const db = makeDb();
2525
expect(await handleOrbIngest(JSON.stringify({ instance_id: 123, events: [] }), db)).toEqual({ error: "invalid_payload" });
2626
expect(await handleOrbIngest(JSON.stringify({ instance_id: "abc", events: "bad" }), db)).toEqual({ error: "invalid_payload" });
2727
expect(await handleOrbIngest(JSON.stringify({ instance_id: "", events: [ev()] }), db)).toEqual({ error: "invalid_payload" });
2828
expect(await handleOrbIngest(JSON.stringify({ instance_id: "abc", events: [] }), db)).toEqual({ error: "invalid_payload" });
29+
expect(await handleOrbIngest(JSON.stringify({ instance_id: "i".repeat(65), events: [ev()] }), db)).toEqual({ error: "invalid_payload" });
2930
});
3031

3132
it("skips events with bad repo_hash / pr_hash / outcome", async () => {
3233
expect(await ingest(makeDb(), [ev({ repo_hash: 99 })])).toEqual({ accepted: 0 });
3334
expect(await ingest(makeDb(), [ev({ repo_hash: "" })])).toEqual({ accepted: 0 });
35+
expect(await ingest(makeDb(), [ev({ repo_hash: "r".repeat(129) })])).toEqual({ accepted: 0 });
3436
expect(await ingest(makeDb(), [ev({ pr_hash: null })])).toEqual({ accepted: 0 });
3537
expect(await ingest(makeDb(), [ev({ pr_hash: "" })])).toEqual({ accepted: 0 });
38+
expect(await ingest(makeDb(), [ev({ pr_hash: "p".repeat(129) })])).toEqual({ accepted: 0 });
3639
expect(await ingest(makeDb(), [ev({ outcome: "opened" })])).toEqual({ accepted: 0 });
3740
});
3841

@@ -57,9 +60,10 @@ describe("handleOrbIngest()", () => {
5760

5861
it("stores gate_reasoncode_bucket string vs null", async () => {
5962
const db = makeDb();
60-
await ingest(db, [ev({ pr_hash: "b1", gate_reasoncode_bucket: "duplicate_risk" }), ev({ pr_hash: "b2" })]);
63+
await ingest(db, [ev({ pr_hash: "b1", gate_reasoncode_bucket: "duplicate_risk" }), ev({ pr_hash: "b2" }), ev({ pr_hash: "b3", gate_reasoncode_bucket: "b".repeat(65) })]);
6164
expect(await col(db, "b1", "gate_reasoncode_bucket")).toBe("duplicate_risk");
6265
expect(await col(db, "b2", "gate_reasoncode_bucket")).toBeNull();
66+
expect(await col(db, "b3", "gate_reasoncode_bucket")).toBeNull();
6367
});
6468

6569
it("clamps time_to_close_ms: valid kept; absent / <1s / >1y → null", async () => {
@@ -212,6 +216,21 @@ describe("POST /v1/orb/ingest route", () => {
212216
expect(res.status).toBe(413);
213217
expect(((await res.json()) as { error: string }).error).toBe("payload_too_large");
214218
});
219+
220+
it("optional collector token (#1285): open when unset; enforced once ORB_INGEST_TOKEN is set", async () => {
221+
const body = JSON.stringify({ instance_id: "abc0", events: [{ repo_hash: "rhash", pr_hash: "phash", outcome: "merged" }] });
222+
const post = (env: Env, authorization?: string) =>
223+
app.request("/v1/orb/ingest", { method: "POST", headers: { "content-type": "application/json", ...(authorization ? { authorization } : {}) }, body }, env);
224+
225+
// Token UNSET → open ingress (the live fleet keeps working with no auth header).
226+
expect((await post(createTestEnv())).status).toBe(200);
227+
// Token SET → a missing or wrong bearer is rejected before the body is parsed.
228+
const env = createTestEnv({ ORB_INGEST_TOKEN: "fleet-secret" });
229+
expect((await post(env)).status).toBe(401);
230+
expect((await post(env, "Bearer wrong")).status).toBe(401);
231+
// Token SET + the matching bearer → accepted.
232+
expect((await post(env, "Bearer fleet-secret")).status).toBe(200);
233+
});
215234
});
216235

217236
describe("Orb instance registry routes (/v1/internal/orb/instances)", () => {

test/unit/selfhost-orb-collector.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r
7474
process.env.ORB_ANONYMIZE = "true";
7575
delete process.env.ORB_AIR_GAP;
7676
delete process.env.ORB_COLLECTOR_URL;
77+
delete process.env.ORB_COLLECTOR_TOKEN;
7778
});
7879
afterEach(() => {
79-
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
80+
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "ORB_COLLECTOR_TOKEN", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
8081
});
8182

8283
it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => {
@@ -173,10 +174,12 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r
173174
await audit(db, "o/r", i, "gate_decision", "merge", `2026-03-0${i}T00:00:00Z`);
174175
await audit(db, "o/r", i, "pr_outcome", "merged", `2026-03-0${i}T01:00:00Z`);
175176
}
176-
let sig: string | undefined;
177-
const n = await exportOrbBatch(db, 3, async (_u, init) => { sig = (init!.headers as Record<string, string>)["x-orb-signature"]; return new Response(null, { status: 200 }); });
177+
process.env.ORB_COLLECTOR_TOKEN = "collector-secret";
178+
let headers: Record<string, string> | undefined;
179+
const n = await exportOrbBatch(db, 3, async (_u, init) => { headers = init!.headers as Record<string, string>; return new Response(null, { status: 200 }); });
178180
expect(n).toBe(3); // batch cap
179-
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
181+
expect(headers?.["x-orb-signature"]).toMatch(/^sha256=[a-f0-9]{64}$/);
182+
expect(headers?.authorization).toBe("Bearer collector-secret");
180183
});
181184

182185
it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => {

0 commit comments

Comments
 (0)