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: 10 additions & 1 deletion packages/loopover-engine/src/discovery-index-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,17 @@ export function normalizeDiscoveryIndexResponse(raw: unknown): ParsedDiscoveryIn
warnings.push("DiscoveryIndexResponse must be a mapping; falling back to an empty candidate list.");
}
const rawCandidates = record && Array.isArray(record.candidates) ? record.candidates : [];
// #6774: the request side clamps page size to MAX_PAGE_LIMIT, but this response comes from the OPTIONAL,
// only-partially-trusted hosted index (see this module's header). A misbehaving or compromised host could
// otherwise return an arbitrarily large `candidates` array and force unbounded client-side normalization. Cap
// it, dropping the overflow with a warning; the retained page and `nextCursor` still round-trip so pagination
// continues from the truncated page.
const boundedCandidates = rawCandidates.length > MAX_PAGE_LIMIT ? rawCandidates.slice(0, MAX_PAGE_LIMIT) : rawCandidates;
if (boundedCandidates.length < rawCandidates.length) {
warnings.push(`DiscoveryIndexResponse returned ${rawCandidates.length} candidates; capping to ${MAX_PAGE_LIMIT} and dropping the rest.`);
}
const candidates: DiscoveryIndexCandidate[] = [];
for (const entry of rawCandidates) {
for (const entry of boundedCandidates) {
const normalized = normalizeDiscoveryIndexCandidate(entry);
if (normalized === null) {
warnings.push("DiscoveryIndexResponse dropped an invalid or boundary-violating candidate.");
Expand Down
9 changes: 9 additions & 0 deletions test/unit/discovery-index-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,14 @@ describe("discovery-index API contract (#4300)", () => {
expect(empty.warnings.join(" ")).toMatch(/must be a mapping/);
expect(normalizeDiscoveryIndexResponse({ candidates: "nope", nextCursor: " " }).response).toMatchObject({ candidates: [], nextCursor: null });
});

it("caps an oversized candidate array at MAX_PAGE_LIMIT (200) with a warning, and still round-trips nextCursor for the truncated page (#6774)", () => {
// A misbehaving/compromised discovery-index host returns far more than the request-side page cap allows.
const oversized = Array.from({ length: 250 }, (_, i) => ({ ...VALID_CANDIDATE, issueNumber: i + 1 }));
const parsed = normalizeDiscoveryIndexResponse({ candidates: oversized, nextCursor: "page2==" });
expect(parsed.response.candidates).toHaveLength(200); // dropped the overflow rather than processing 250
expect(parsed.warnings.some((w) => /returned 250 candidates; capping to 200/.test(w))).toBe(true);
expect(parsed.response.nextCursor).toBe("page2=="); // truncation must not break forward pagination
});
});
});