Skip to content

Commit b28db1b

Browse files
authored
fix(content-lane,federated,apr): close seven pre-flag defects — public-safe scrub bypass, probe OOM/fan-out, self-corroborating grounding, identity farming, peer takeover, and APR tenant binding (#9490) (#9535)
1 parent d6c4334 commit b28db1b

12 files changed

Lines changed: 626 additions & 26 deletions

src/orb/apr-repo-binding.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Trusted server-side APR repo→customer binding lookup for transfer authorization (#9490).
2+
//
3+
// `installationId`, `repoFullName` and `newOwner` are ALL caller-supplied on the transfer route, so without a
4+
// server-side record binding an APR repo to the customer it belongs to, any authorized caller could transfer
5+
// any completed APR repo — including another customer's — to themselves the moment #7664 starts persisting
6+
// completion records. This gate exists NOW, while the route is still inert, precisely so completion landing
7+
// later cannot silently arm an unauthorized-transfer primitive.
8+
//
9+
// Until #7664 persists a binding record, this ALWAYS returns null (fail closed) and
10+
// `requestAprRepoTransfer` rejects on a null binding. A client-supplied binding must never substitute for
11+
// this — same contract, same replace-the-body-keep-the-signature instruction, and the same reasoning as
12+
// ./apr-idea-completion.ts (whose shape this module deliberately mirrors, down to living in its own file so
13+
// the route's trusted lookup is replaceable at the import seam).
14+
15+
export type AprRepoBinding = {
16+
/** The GitHub login of the customer whose OAuth session created (or owns) this APR repo. */
17+
customerLogin: string;
18+
/** The installation the APR repo actually belongs to. */
19+
installationId: number;
20+
};
21+
22+
export type AprRepoBindingLookup = (env: Env, input: { repoFullName: string }) => Promise<AprRepoBinding | null>;
23+
24+
/**
25+
* Resolve the tenant binding for an APR repo (#9490). Fail-closed until a persisted record exists: today's
26+
* body always returns null. Declared return is `AprRepoBinding | null` so a future persisted lookup (and test
27+
* doubles) can return a real binding; a null always rejects the transfer.
28+
*/
29+
export async function loadAprRepoBinding(_env: Env, _input: { repoFullName: string }): Promise<AprRepoBinding | null> {
30+
return null;
31+
}

src/orb/apr-repo-transfer.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ import { upsertRepositorySettings } from "../db/repositories";
1313
import { createInstallationToken } from "../github/app";
1414
import { githubHeaders, timeoutFetch } from "../github/client";
1515
import { loadAprIdeaCompletion, type AprIdeaCompletionLookup } from "./apr-idea-completion";
16+
import { loadAprRepoBinding as loadAprRepoBindingDefault, type AprRepoBinding, type AprRepoBindingLookup } from "./apr-repo-binding";
1617
// `Env` is the ambient Cloudflare Worker binding interface (worker-configuration.d.ts) — a global, not imported.
1718

1819
export type { AprIdeaCompletionLookup, AprIdeaCompletionLookupInput } from "./apr-idea-completion";
1920
export { loadAprIdeaCompletion } from "./apr-idea-completion";
2021

22+
export type { AprRepoBinding, AprRepoBindingLookup } from "./apr-repo-binding";
23+
export { loadAprRepoBinding } from "./apr-repo-binding";
24+
2125
/**
2226
* Result of initiating an APR repo transfer.
2327
*
@@ -54,10 +58,36 @@ export type RequestAprRepoTransferInput = {
5458
* GitHub accepted a *pending* transfer (see {@link AprRepoTransferResult}), never "transfer done".
5559
*/
5660
export type RequestAprRepoTransferResult =
57-
| { status: "rejected"; reason: "idea_not_complete" }
61+
| { status: "rejected"; reason: "idea_not_complete" | AprRepoTransferBindingRejection }
5862
| { status: "initiated"; transfer: Extract<AprRepoTransferResult, { initiated: true }> }
5963
| { status: "failed"; transfer: Extract<AprRepoTransferResult, { initiated: false }> };
6064

65+
/** #9490: the three ways the tenant binding can refuse a transfer. Distinct reasons on purpose — an operator
66+
* debugging a refused transfer needs to know WHICH leg failed, and none of them leak anything the caller did
67+
* not already supply. */
68+
export type AprRepoTransferBindingRejection =
69+
/** No server-side binding record exists for this repo at all — either not an APR repo, or #7664 has not
70+
* persisted its record. Fail closed: an unbound repo is never transferable. */
71+
| "repo_not_apr_bound"
72+
/** The caller-supplied `newOwner` is not the customer this APR repo is bound to. The whole point: a transfer
73+
* may only ever move a repo to its OWN customer, never to whoever asked. */
74+
| "new_owner_not_bound_customer"
75+
/** The caller-supplied `installationId` is not the installation the binding records for this repo. */
76+
| "installation_not_bound";
77+
78+
/** #9490: pure tenant-binding check, separated from the completion gate so each is independently testable. */
79+
export function evaluateAprRepoTransferBinding(
80+
input: Pick<RequestAprRepoTransferInput, "installationId" | "repoFullName" | "newOwner">,
81+
binding: AprRepoBinding | null,
82+
): { allowed: true } | { allowed: false; reason: AprRepoTransferBindingRejection } {
83+
if (binding === null) return { allowed: false, reason: "repo_not_apr_bound" };
84+
if (binding.customerLogin.toLowerCase() !== input.newOwner.trim().toLowerCase()) {
85+
return { allowed: false, reason: "new_owner_not_bound_customer" };
86+
}
87+
if (binding.installationId !== input.installationId) return { allowed: false, reason: "installation_not_bound" };
88+
return { allowed: true };
89+
}
90+
6191
/** Decide whether a customer may request an APR repo transfer right now (#7742). Pure and deterministic. */
6292
export function evaluateAprRepoTransferRequestEligibility(input: {
6393
ideaComplete: boolean;
@@ -116,6 +146,8 @@ export async function requestAprRepoTransfer(
116146
newOwner: string,
117147
) => Promise<AprRepoTransferResult>;
118148
loadCompletion?: AprIdeaCompletionLookup;
149+
/** #9490 seam: the tenant-binding lookup; injectable for tests, fail-closed default. */
150+
loadBinding?: AprRepoBindingLookup;
119151
/** #7741 deliverable 2 seam: how to freeze AMS dispatch once a transfer is pending. Injectable for tests. */
120152
pauseDispatch?: (env: Env, repoFullName: string) => Promise<void>;
121153
} = {},
@@ -125,6 +157,15 @@ export async function requestAprRepoTransfer(
125157
const eligibility = evaluateAprRepoTransferRequestEligibility({ ideaComplete });
126158
if (!eligibility.allowed) return { status: "rejected", reason: eligibility.reason };
127159

160+
// #9490: the tenant binding gates AFTER completion (cheapest rejection first is irrelevant here -- both are
161+
// local lookups -- but completion-first keeps today's observable behaviour byte-identical: an incomplete
162+
// idea still rejects with the same reason it always did) and BEFORE any GitHub call: a transfer request
163+
// that fails authorization must never reach GitHub at all, not even to fail there.
164+
const loadBinding = options.loadBinding ?? loadAprRepoBindingDefault;
165+
const binding = await loadBinding(env, { repoFullName: input.repoFullName });
166+
const bindingCheck = evaluateAprRepoTransferBinding(input, binding);
167+
if (!bindingCheck.allowed) return { status: "rejected", reason: bindingCheck.reason };
168+
128169
const initiate = options.initiate ?? initiateAprRepoTransfer;
129170
const transfer = await initiate(env, input.installationId, input.repoFullName, input.newOwner);
130171
if (transfer.initiated) {
@@ -217,7 +258,21 @@ export async function probeAprRepoTransfer(
217258
const response = await timeoutFetch(`https://api.github.com/repos/${transfer.repoFullName}`, {
218259
headers: githubHeaders({ token }),
219260
});
220-
if (response.status === 404) return { state: "access_departed" };
261+
// #9490: a plain 404 at the original path is AMBIGUOUS -- ownership moved away, OR the repo was simply
262+
// deleted / the App uninstalled. Recording "accepted_departed" (a terminal SUCCESS) for a deleted repo is
263+
// a false outcome in the transfer ledger, so departure is only declared once the repo demonstrably
264+
// resolves under the TARGET owner. When that corroboration cannot be obtained (a private repo the App can
265+
// no longer see), the transfer stays "pending" and the existing expiry clock resolves it -- a bounded,
266+
// late, truthful answer over a fast wrong one.
267+
if (response.status === 404) {
268+
const repoName = transfer.repoFullName.split("/")[1] ?? "";
269+
const relocated = repoName ? await timeoutFetch(`https://api.github.com/repos/${transfer.newOwner}/${repoName}`, { headers: githubHeaders({ token }) }).catch(() => null) : null;
270+
if (relocated?.ok) {
271+
const relocatedBody = (await relocated.json().catch(() => null)) as { owner?: { login?: string } } | null;
272+
if (relocatedBody?.owner?.login?.toLowerCase() === transfer.newOwner.toLowerCase()) return { state: "access_departed" };
273+
}
274+
return { state: "pending" };
275+
}
221276
if (!response.ok) return { state: "pending" };
222277
const body = (await response.json().catch(() => null)) as { owner?: { login?: string } } | null;
223278
const owner = body?.owner?.login;

src/orb/federated-import.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ const MAX_BUNDLE_AGE_MS = 7 * 86_400_000;
4747
* self-hosted instances without opening a meaningful backdating/replay window. */
4848
const MAX_CLOCK_SKEW_MS = 5 * 60_000;
4949

50+
/** #9490: hard cap on `instanceId` length -- see isBundleBodyShaped's own comment for why this is a
51+
* persistence-integrity bound, not a cosmetic one. */
52+
export const MAX_INSTANCE_ID_CHARS = 128;
53+
5054
/** How many DISTINCT `instanceId`s a single verifying key may ever contribute to the accepted set (#9148).
5155
* Without this, one allowlisted key can mint an unbounded number of fabricated instanceIds and dominate the
5256
* peer median outright — the allowlist bounds which KEYS are trusted, not how many PEERS a key may claim to
@@ -90,6 +94,11 @@ export type FederatedRejectionReason =
9094
* otherwise-valid bundle (#9148, the persisted high-water mark). Only ever produced by
9195
* {@link applyFederatedPeerWatermarks}, which is the one place this pipeline touches the DB. */
9296
| "replayed_or_rollback"
97+
/** #9490: this `instanceId` is already bound to a DIFFERENT verifying key. First-writer-wins: the id was
98+
* admitted under one key's fingerprint, and a bundle for the same id verifying under any other allowlisted
99+
* key is rejected outright — the takeover primitive (shadow an honest peer's id, ratchet its watermark,
100+
* suppress its genuine bundles) must not exist even between two currently-trusted keys. */
101+
| "instance_key_conflict"
93102
/** This `instanceId` is new, and admitting it would push its verifying key over MAX_INSTANCES_PER_KEY —
94103
* the per-key Sybil cap (#9148). Only ever produced by {@link applyFederatedPeerWatermarks}. */
95104
| "sybil_cap_exceeded";
@@ -136,6 +145,13 @@ function isBundleBodyShaped(bundle: FederatedSignalBundle): boolean {
136145
const nullableNumeric = (value: unknown): boolean => value === null || numeric(value);
137146
return (
138147
typeof bundle.instanceId === "string" &&
148+
// #9490: bounded, because instanceIds are PERSISTED into the single system_flags peer-state blob. Nothing
149+
// else bounded them, and the pull body cap is 1 MB total -- so roughly 3-4 bundles carrying ~600 KB ids
150+
// pushed that blob past D1's ~2 MB value limit, writeFederatedPeerState swallowed the failure, and replay/
151+
// rollback watermarks silently stopped persisting for EVERY peer. 128 chars fits every reasonable id
152+
// scheme (a UUID is 36, a hex fingerprint 64) with headroom.
153+
bundle.instanceId.length > 0 &&
154+
bundle.instanceId.length <= MAX_INSTANCE_ID_CHARS &&
139155
typeof bundle.generatedAt === "string" &&
140156
typeof bundle.signature === "string" &&
141157
numeric(bundle.windowDays) &&
@@ -345,7 +361,22 @@ async function writeFederatedPeerState(db: D1Database, state: FederatedPeerState
345361
.prepare("INSERT OR REPLACE INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)")
346362
.bind(FEDERATED_PEER_STATE_FLAG_KEY, JSON.stringify(state))
347363
.run()
348-
.catch(() => undefined);
364+
.catch((error: unknown) => {
365+
// #9490: still fail-open (a write failure must never fail the sync tick), but LOUDLY. This blob carries
366+
// the replay/rollback watermarks and the Sybil cap's instance ledger for every peer -- a persistent
367+
// write failure silently regresses all of #9148's protections, which an operator needs to SEE, not
368+
// infer. error level so the structured-log forwarder picks it up, same convention as
369+
// regate_repair_exhausted.
370+
console.error(
371+
JSON.stringify({
372+
level: "error",
373+
event: "federated_peer_state_write_failed",
374+
instances: Object.keys(state).length,
375+
approxBytes: JSON.stringify(state).length,
376+
message: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
377+
}),
378+
);
379+
});
349380
}
350381

351382
/**
@@ -394,6 +425,19 @@ export async function applyFederatedPeerWatermarks(
394425
const generatedAtMs = Date.parse(bundle.generatedAt); // already validated finite by importPeerBundles' rejectionFor
395426
const existing = state[bundle.instanceId];
396427
if (existing) {
428+
// #9490: an instanceId is BOUND to the first key that verified it. Without this, any hostile-but-
429+
// allowlisted peer B could claim honest peer A's id: shadow A's bundle in-batch (last-wins dedup keys
430+
// on id alone), overwrite the watermark entry's keyFingerprint, then ratchet lastGeneratedAtMs forward
431+
// so A's genuine bundles reject as replayed_or_rollback forever -- targeted suppression plus stat
432+
// replacement, and a bypass of B's own MAX_INSTANCES_PER_KEY cap (only NEW ids are counted against it).
433+
// That sits squarely inside #9148's declared threat model: bound the damage one still-trusted key can
434+
// do. The binding is first-writer-wins and permanent for the life of the state entry; a peer that
435+
// legitimately rotates keys re-enters under a new id (or after PEER_STATE_PRUNE_AFTER_MS frees the old
436+
// one), which is the cheap, honest path -- as opposed to any takeover path existing at all.
437+
if (existing.keyFingerprint !== fingerprint) {
438+
rejected.push(reject(bundle.instanceId, "instance_key_conflict"));
439+
continue;
440+
}
397441
if (generatedAtMs <= existing.lastGeneratedAtMs) {
398442
rejected.push(reject(bundle.instanceId, "replayed_or_rollback"));
399443
continue;

src/review/content-lane/orchestrator.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,34 @@ export function diffAppendedSurfaceEntries(headRaw: string | null, baseRaw: stri
7272
const headEntries = surfacesOf(safeParseJson(headRaw), field);
7373
if (headEntries === null) return null;
7474
const baseEntries = surfacesOf(safeParseJson(baseRaw), field) ?? [];
75-
const baseKeys = new Set(baseEntries.map((entry) => JSON.stringify(entry)));
76-
return headEntries.filter((entry) => !baseKeys.has(JSON.stringify(entry)));
75+
const baseKeys = new Set(baseEntries.map((entry) => canonicalEntryKey(entry)));
76+
return headEntries.filter((entry) => !baseKeys.has(canonicalEntryKey(entry)));
77+
}
78+
79+
/**
80+
* #9490: entry identity, KEY-ORDER INVARIANT. Both structural diffs below used raw `JSON.stringify(entry)` as
81+
* the identity key, and JSON.stringify preserves insertion order -- so reordering an existing entry's keys
82+
* (`{url, kind}` -> `{kind, url}`) read as a FRESH append while simultaneously removing the entry's prior self
83+
* from survivingExistingEntries' scope, taking it out of the duplicate check too. That is a recipe for
84+
* unbounded content-free auto-merged registry PRs: each one "contributes" a reorder, farms a merge, and leaves
85+
* the registry byte-different but semantically identical. Sorting keys recursively makes a reorder read as
86+
* exactly what it is -- zero appended entries. Arrays keep their order (order IS meaning there, same rule as
87+
* decision-record.ts's canonicalJson).
88+
*/
89+
export function canonicalEntryKey(entry: unknown): string {
90+
return JSON.stringify(sortKeysDeep(entry));
91+
}
92+
93+
function sortKeysDeep(value: unknown): unknown {
94+
if (Array.isArray(value)) return value.map(sortKeysDeep);
95+
if (value !== null && typeof value === "object") {
96+
return Object.fromEntries(
97+
Object.keys(value as Record<string, unknown>)
98+
.sort()
99+
.map((key) => [key, sortKeysDeep((value as Record<string, unknown>)[key])]),
100+
);
101+
}
102+
return value;
77103
}
78104

79105
/**
@@ -100,8 +126,10 @@ export function survivingExistingEntries(headRaw: string | null, baseRaw: string
100126
const headEntries = surfacesOf(safeParseJson(headRaw), field);
101127
if (headEntries === null) return [];
102128
const baseEntries = surfacesOf(safeParseJson(baseRaw), field) ?? [];
103-
const headKeys = new Set(headEntries.map((entry) => JSON.stringify(entry)));
104-
return baseEntries.filter((entry) => headKeys.has(JSON.stringify(entry)));
129+
// #9490: same canonical key as diffAppendedSurfaceEntries, and for the same reason -- a key-reordered entry
130+
// must still count as "surviving" so its identity stays inside the duplicate check's scope.
131+
const headKeys = new Set(headEntries.map((entry) => canonicalEntryKey(entry)));
132+
return baseEntries.filter((entry) => headKeys.has(canonicalEntryKey(entry)));
105133
}
106134

107135
function fromProvider(assessment: ProviderAssessment): SurfaceReviewResult {
@@ -212,15 +240,38 @@ function pickAggregateAssessment(assessments: Assessment[]): Assessment {
212240
* for review instead. `makeSurfaceEntryVerifier` is itself written not to throw, so this is a belt-and-braces
213241
* guard against a future/third-party verifier that is less careful.
214242
*/
243+
/**
244+
* #9490: hard ceiling on live verifications per review run. Each verification fetches up to 2 URLs, each
245+
* following up to 5 hops with a 10s per-hop timeout -- so an uncapped run over a spec with
246+
* `maxAppendedEntries: Infinity` (metagraphed's documented policy) let a 500-entry PR command thousands of
247+
* outbound subrequests from the bot: a request-amplification primitive, and on Workers a subrequest-exhaustion
248+
* path that converts into bulk `probe_fetch_failed` holds for everyone else in the isolate. Ten matches
249+
* source-evidence.ts's own fan-out cap. Entries past the cap are HELD for a human, never merged unverified --
250+
* the cap bounds spend, it must not become a way to sneak entry #11 through unprobed.
251+
*/
252+
export const MAX_VERIFIED_ENTRIES_PER_RUN = 10;
253+
215254
async function verifyMergedEntries(
216255
staticAssessments: Assessment[],
217256
appendedEntries: readonly unknown[],
218257
verifyEntry: SurfaceReviewInput["verifyEntry"],
219258
): Promise<Assessment[]> {
220259
if (!verifyEntry) return staticAssessments;
260+
let verificationsStarted = 0;
221261
return await Promise.all(
222262
staticAssessments.map(async (assessment, idx) => {
223263
if (assessment.verdict !== "merged") return assessment;
264+
// Counted SYNCHRONOUSLY, before any await, so the concurrent map cannot race the budget check --
265+
// .map's callbacks run their synchronous prefix in order, so exactly the first N merged entries verify.
266+
if (verificationsStarted >= MAX_VERIFIED_ENTRIES_PER_RUN) {
267+
return {
268+
verdict: "manual-review" as const,
269+
summary: `This PR appends more than ${MAX_VERIFIED_ENTRIES_PER_RUN} entries needing live verification; this entry is past that per-run probe budget, so it is routed to review rather than accepted unverified.`,
270+
candidate: assessment.candidate,
271+
reason: "verification-capacity",
272+
};
273+
}
274+
verificationsStarted += 1;
224275
try {
225276
return (await verifyEntry(appendedEntries[idx])) ?? assessment;
226277
} catch {

0 commit comments

Comments
 (0)