Security hardening — round 9: 9 confirmed defects - #19
Merged
Conversation
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
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 <option> in the sort, badge and group-by selectors — which render outside the collection error boundary, so an entity with tens of thousands of matching keys froze or OOM-killed the tab on a single synchronous commit. Bound discovery at MAX_AVAILABLE_FIELDS (100), stopping the per-card walk early so a hostile key space cannot make discovery allocate without bound, matching the MAX_DISPLAYABLE_FIELDS cap on card-detail rows. Assisted-by: Claude:claude-fable-5
card.detailUrls was assembled from the untrusted, uncapped detailUrls array plus one link per entry of the uncapped videos array. The MAX_MEDIA_PER_CARD cap five lines above applies only to the gallery list, not to this one, so a hostile entity carrying a huge videos array produced tens of thousands of DetailLinks. Opening that card's Sources button then mounted one anchor (plus several URL parses) per link with no windowing, freezing the tab; in list/compact layouts the CardExpanded memo also filtered the whole list per card on first paint. Cap the assembled list at MAX_DETAIL_LINKS_PER_CARD, matching the media cap at the same choke point. Assisted-by: Claude:claude-fable-5
A collection's forced `rankPlaceholderText` (settings.json) and its v2 `uiLabels.rankPlaceholder` are rendered once per unranked card with no virtualisation, so an unbounded string is amplified by the entity count into a tab-freezing layout pass — and the forced value persists into global settings, reproducing on reload before the user can reach the settings panel. Cap the forced value at ingress (MAX_LABEL_LENGTH) alongside the existing searchFields/maxVisibleCards bounds, and truncate the v2 UI labels in the schema. Truncation (not rejection) keeps an honest author's one long label from denying the whole collection load, since validateCollectionDefinition throws on a schema failure. Assisted-by: Claude:claude-fable-5
The settings-import schema accepted an unbounded searchFields array and an unbounded rankPlaceholderText, the same values the collection loader already caps (MAX_SEARCH_FIELDS, MAX_LABEL_LENGTH). Both persist into global settings and follow the visitor to later collections, so an imported file could freeze every later search (searchFields resolved per card per query) or every card render (rank placeholder per unranked card) with no UI to undo it. Cap both on import, matching the auto-load path. Assisted-by: Claude:claude-fable-5
The filter dropdown offered Platform options collected from a card's short title but matched them against the full categoryTitle, so selecting any platform emptied the grid whenever the two differed (the normal case, e.g. "Switch" vs "Nintendo Switch"). The Genre filter listed every genre but matched only each card's first one, silently dropping cards whose matching genre sat at a later index. Move the field names, option source and match predicate into one module (filterMatch) so the two sides cannot drift, match array-valued fields by membership, and collect platform options from the same categoryShort field the filter now matches on. Assisted-by: Claude:claude-fable-5
The source store declares a persist version (already bumped 1->2->3 across releases) but no migrate function, so when the persisted version differs zustand hands `undefined` to merge. merge dereferenced `persisted.sources` on its first line and threw; the throw was swallowed (no onRehydrateStorage), so every source the user had configured silently vanished on the upgrade — and every future bump would repeat it. Add a migrate that carries the persisted state forward (letting merge's existing dedup/legacy-cleanup run as the real migration) and make merge undefined-safe so a fresh load no longer throws either. Assisted-by: Claude:claude-fable-5
…port The round-7/8 fix preserved hasAppliedCollectionDefaults across a replace-mode settings import but not appliedCollectionDefaultsSourceId, the marker applyCollectionSettings compares against the active source to gate a collection's settings.json defaults. resetToDefaults() nulls it, so after a replace import the next load of the still-active collection saw the guard open again and re-applied its defaults over the just-imported values — the same silent discard the original fix targeted, on the settings.json path. Snapshot and restore both markers (adding the missing setAppliedCollectionDefaultsSourceId action). Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-fable-5
The round-9 filter refactor renamed the Platform filter field from categoryTitle to categoryShort but left a stale field-name branch in the dropdown count badge, so the Platform row showed the genre count (or 0) instead of the platform count. Drive the badge off the filter field's own options array, matching the single-source-of-truth the refactor intended. Assisted-by: Claude:claude-fable-5
The round-9 detail-link cap covered only the card's own detailUrls/videos, not the platform overlay's categoryInfo.detailUrls, which is built from the untrusted platform entity's detailUrls with no bound. deduplicateLinksBySource keys on source/label/url, so a hostile platform carrying tens of thousands of distinct-source links survives dedup and mounts one anchor (plus several URL parses) each in one synchronous commit — the same freeze, reachable by moving the payload from entity.videos to platform.detailUrls. Slice the platform links at the same MAX_DETAIL_LINKS_PER_CARD choke point. Assisted-by: Claude:claude-fable-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated security-hardening round 9. Each finding was surfaced by parallel hunters, survived an independent adversarial refuter, and ships with a reproduction test observed failing before the fix and passing after. 18 reproduction tests added (994 → 1012). No dependency changes.
Confirmed defects fixed
All reachable from an untrusted remote collection (loaded from allowlisted CDNs via user-pasted URLs /
/gh/<user>/share links) or a user-imported settings file, unless noted.relationshipResolver.ts,collectionLoader.ts) — therelationshipsrecord is unbounded andtype.fieldkeyed, so a record whose keys all share the primary type's prefix collapsed into one bucket and made both the resolve pass and the per-card rank passO(relationships × entities)again (round 7 bounded only the per-entity rebuild). Capped atMAX_RELATIONSHIPS; the single-file entity-array paths now share theMAX_ENTITY_IDSceiling the index path enforced, removing the entity-count multiplier.useAvailableFields.ts) — every key of the sampled.loose()entities became an<option>in the settings sort/badge/group-by selectors, which render outside the collection error boundary, so an entity with tens of thousands of keys froze/OOM-killed the tab. Capped atMAX_AVAILABLE_FIELDS, stopping the per-card walk early.useCollection.ts) —card.detailUrlsescapedMAX_MEDIA_PER_CARD(built from the uncappeddetailUrlsarray plus one link per uncappedvideosentry), so the sources overlay mounted an anchor + several URL parses per link with no windowing. Capped atMAX_DETAIL_LINKS_PER_CARD.settingsLoader.ts,collection.schema.ts) — a forcedrankPlaceholderTextand the v2uiLabels.rankPlaceholderare rendered once per unranked card with no virtualisation, amplifying an unbounded string by the card count (the forced value persists globally). Forced value capped at ingress; v2 labels truncated in the schema (truncation, not rejection, so one long label cannot deny the whole collection load).settingsExport.schema.ts) — the import schema accepted an unboundedsearchFieldsarray andrankPlaceholderText, the same values the collection loader already caps, which persist globally and follow the visitor to later collections. Both bounded on import.sourceStore.ts) — the store declares a persist version (already bumped 1→2→3) but nomigrate, so a version mismatch handedundefinedtomerge, which threw on.sources; the swallowed throw discarded every configured source on upgrade. Added amigrate(carrying the persisted state forward so the existing dedup/legacy-cleanup runs as the migration) and mademergeundefined-safe.filterMatch.ts,FilterChips.tsx,CardGrid.tsx) — the Platform filter offered short-title options but matched the fullcategoryTitle(emptying the grid whenever they differed), and the Genre filter listed every genre but matched only each card's first. The field names, option source and match predicate now live in one module so the two sides cannot drift; array-valued fields match by membership; platform options are collected from the samecategoryShortfield the filter matches on. (Correctness, not security.)settingsExport.ts,settingsStore.ts) — the round-7/8 fix preservedhasAppliedCollectionDefaultsbut notappliedCollectionDefaultsSourceId, the markerapplyCollectionSettingscompares against the active source, so the active collection'ssettings.jsondefaults re-armed to clobber the just-imported values on the next load. Both markers are now snapshotted and restored (adding the missingsetAppliedCollectionDefaultsSourceIdaction).Considered and rejected (not fixed)
tsc -b-emittedvite.config.jsshadowingvite.config.ts— real, but the production build is a clean checkout so the shipped config never diverges; a local-dev staleness wart, not a security exposure.test-results/artefacts — tracked and un-gitignored, but the files carry no PII/secrets and there is no binary-trace leak channel on developer machines (traces only on CI retries, ephemeral workspace). Hygiene nitpick.?reset=1substring collision — latent: nopresetquery param exists or is planned, and the documented reset clears only local display settings...collection-path traversal spoof (two independent refuters) — grants nothing the design-intended?collection=public-CDN route already grants; the loading screen even renders the traversal string.Verification
npm run typecheck,npm run lint(0 errors),npm run test(1012 pass), andnpm run buildall pass locally.npm audit --omit=devreports 0 advisories in the production tree. Each behaviour change has a reproduction test observed failing onmainand passing on this branch.