From 60aee6c293014103b812e183511e76d4b580476b Mon Sep 17 00:00:00 2001 From: Alex Reppel <77722411+REPPL@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:28:52 +0000 Subject: [PATCH 01/11] fix: bound relationship definitions and single-file entity arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An untrusted collection.json's `relationships` record is unbounded and its keys are `type.field`. Grouping keeps per-entity work proportional to one type's bucket, but a record whose keys all share the primary type's prefix collapses into a single bucket, so both the resolve pass and the per-card rank pass become O(relationships x entities) — a synchronous multi-minute main-thread freeze on a crafted payload. Cap the honoured relationships at MAX_RELATIONSHIPS, matching the existing entity-type and entity-id caps. The single-file entity paths (`{type}s.json` / `{type}.json` served as one array) mapped an unbounded array, bypassing the MAX_ENTITY_IDS ceiling the index-driven path enforces and supplying the entity-count multiplier for the relationship product. Route both through a shared cap. Assisted-by: Claude:claude-fable-5 --- src/loaders/collectionLoader.ts | 42 ++++++++++++------- src/loaders/relationshipResolver.ts | 27 ++++++++++-- tests/loaders/collectionLoader.test.ts | 14 +++++++ tests/loaders/relationshipResolver.test.ts | 48 ++++++++++++++++++++++ 4 files changed, 114 insertions(+), 17 deletions(-) diff --git a/src/loaders/collectionLoader.ts b/src/loaders/collectionLoader.ts index 682efc8..f6995fe 100644 --- a/src/loaders/collectionLoader.ts +++ b/src/loaders/collectionLoader.ts @@ -198,6 +198,32 @@ function extractEntityIds(data: unknown, pluralType: string): string[] { * @param entityType - Type of entities to load (singular form, e.g., "advert") * @returns Array of entities */ +/** + * Parse a single-file entity array into deduplicated entities. + * + * The index-driven path caps its id list at MAX_ENTITY_IDS, but a collection + * can skip index.json and serve `{type}s.json` as one array. Apply the same + * ceiling here so an untrusted single file cannot exceed the entity count the + * rest of the pipeline (relationship resolution, per-card rendering) is sized + * for. + */ +function parseEntityArray(data: unknown[], urlLabel: string): Entity[] { + let rows = data; + if (rows.length > MAX_ENTITY_IDS) { + console.warn( + `Entity file lists ${String(rows.length)} entities; loading the first ${String(MAX_ENTITY_IDS)}.` + ); + rows = rows.slice(0, MAX_ENTITY_IDS); + } + return dedupeEntitiesById( + rows + .map((item, index) => + parseEntityTolerant(item, `${urlLabel}[${String(index)}]`) + ) + .filter((entity): entity is Entity => entity !== null) + ); +} + export async function loadEntities( basePath: string, entityType: string @@ -270,13 +296,7 @@ export async function loadEntities( const data = (await response.json()) as unknown; if (Array.isArray(data)) { - return dedupeEntitiesById( - data - .map((item, index) => - parseEntityTolerant(item, `${pluralFileUrl}[${String(index)}]`) - ) - .filter((entity): entity is Entity => entity !== null) - ); + return parseEntityArray(data, pluralFileUrl); } const single = parseEntityTolerant(data, pluralFileUrl); @@ -299,13 +319,7 @@ export async function loadEntities( const data = (await response.json()) as unknown; if (Array.isArray(data)) { - return dedupeEntitiesById( - data - .map((item, index) => - parseEntityTolerant(item, `${singleFileUrl}[${String(index)}]`) - ) - .filter((entity): entity is Entity => entity !== null) - ); + return parseEntityArray(data, singleFileUrl); } // Single entity in file diff --git a/src/loaders/relationshipResolver.ts b/src/loaders/relationshipResolver.ts index 6b9d323..cb4bfc5 100644 --- a/src/loaders/relationshipResolver.ts +++ b/src/loaders/relationshipResolver.ts @@ -11,6 +11,21 @@ import type { RelationshipDefinition, } from "@/types/schema"; +/** + * Upper bound on the number of relationship definitions honoured from an + * untrusted collection.json. + * + * The relationship record is `type.field` keyed and unbounded in the schema. + * Grouping keeps the per-entity work proportional to the entries for one type, + * but a record where every key shares the primary type's prefix collapses into + * a single bucket, so both the resolve pass and the per-card rank pass become + * O(relationships x entities) — a synchronous multi-minute main-thread freeze + * on a crafted payload. Cap the record the way entity types and ids are + * already capped (MAX_ENTITY_TYPES, MAX_ENTITY_IDS). Real collections declare + * a handful of relationships, so the ceiling is far above any honest use. + */ +export const MAX_RELATIONSHIPS = 200; + /** * Context for resolving relationships. */ @@ -65,9 +80,15 @@ export function createResolverContext( [string, RelationshipDefinition][] >(); - for (const [relKey, relDef] of Object.entries( - definition.relationships ?? {} - )) { + const relationshipEntries = Object.entries(definition.relationships ?? {}); + if (relationshipEntries.length > MAX_RELATIONSHIPS) { + console.warn( + `Collection declares ${String(relationshipEntries.length)} relationships; ` + + `only the first ${String(MAX_RELATIONSHIPS)} are resolved.` + ); + } + + for (const [relKey, relDef] of relationshipEntries.slice(0, MAX_RELATIONSHIPS)) { const [relType, fieldName] = relKey.split("."); if (!relType || !fieldName) { continue; diff --git a/tests/loaders/collectionLoader.test.ts b/tests/loaders/collectionLoader.test.ts index b5b0a9f..9e667f9 100644 --- a/tests/loaders/collectionLoader.test.ts +++ b/tests/loaders/collectionLoader.test.ts @@ -205,6 +205,20 @@ describe("entity fetch fan-out is bounded", () => { expect(entities).toHaveLength(10000); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("10001")); }); + + it("caps a single-file entity array that exceeds the maximum", async () => { + // Skipping index.json and serving one big array must not bypass the id cap + // the index-driven path enforces. + const rows = Array.from({ length: 10001 }, (_, i) => ({ + id: `e${String(i)}`, + })); + stubFetch({ [`${base}/adverts.json`]: rows }); + + const entities = await loadEntities(base, "advert"); + + expect(entities).toHaveLength(10000); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("10001")); + }); }); describe("entity-type fan-out is bounded", () => { diff --git a/tests/loaders/relationshipResolver.test.ts b/tests/loaders/relationshipResolver.test.ts index b627295..40c80d0 100644 --- a/tests/loaders/relationshipResolver.test.ts +++ b/tests/loaders/relationshipResolver.test.ts @@ -9,6 +9,7 @@ import { resolveEntityRelationships, resolveAllRelationships, getEntityRank, + MAX_RELATIONSHIPS, } from "@/loaders/relationshipResolver"; import type { CollectionDefinition, Entity } from "@/types/schema"; @@ -284,4 +285,51 @@ describe("relationshipResolver with an attacker-scaled definition", () => { expect(elapsed).toBeLessThan(1500); }); + + /** + * Grouping keeps the per-entity work proportional to one type's bucket, but a + * record whose keys all share the primary type's prefix collapses into a + * single bucket. Without a cap that bucket is |relationships| entries, so the + * resolve and rank passes become O(relationships x entities) again. + */ + function samePrefixDefinition(relationshipCount: number): CollectionDefinition { + const relationships: CollectionDefinition["relationships"] = {}; + for (let i = 0; i < relationshipCount; i++) { + relationships[`game.field${String(i)}`] = {}; + } + return { + id: "hostile", + name: "Hostile", + entityTypes: { game: { primary: true, fields: {} } }, + relationships, + }; + } + + it("caps the relationships honoured for a single entity type", () => { + const definition = samePrefixDefinition(MAX_RELATIONSHIPS + 500); + const context = createResolverContext(definition, { game: [] }); + + const bucket = context.relationshipsByType.get("game") ?? []; + expect(bucket.length).toBeLessThanOrEqual(MAX_RELATIONSHIPS); + }); + + it("resolves a same-prefix relationship flood without super-linear cost", () => { + const definition = samePrefixDefinition(20000); + const entities: Entity[] = Array.from({ length: 1000 }, (_, i) => ({ + id: `g${String(i)}`, + title: `Game ${String(i)}`, + })); + const context = createResolverContext(definition, { game: entities }); + + const started = Date.now(); + resolveAllRelationships("game", context); + for (const entity of entities) { + getEntityRank(entity, "game", context); + } + const elapsed = Date.now() - started; + + // Pre-fix the "game" bucket held all 20000 entries, so this was + // 20000 x 1000 iterations twice over — a multi-second freeze. + expect(elapsed).toBeLessThan(1500); + }); }); From 2ec5f5a3b6095d4002268586f2fd2b204b7f8c4b Mon Sep 17 00:00:00 2001 From: Alex Reppel <77722411+REPPL@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:29:53 +0000 Subject: [PATCH 02/11] fix: cap discovered field options in the settings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entity schemas are loose, so an untrusted collection can carry an unbounded number of keys, all copied onto the display card. useAvailableFields walked every key of the sampled cards and turned each into an