Skip to content

Security hardening — round 9: 9 confirmed defects - #19

Merged
REPPL merged 11 commits into
mainfrom
security-hunt/round-9
Aug 13, 2026
Merged

Security hardening — round 9: 9 confirmed defects#19
REPPL merged 11 commits into
mainfrom
security-hunt/round-9

Conversation

@REPPL

@REPPL REPPL commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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.

  1. Relationship / single-file entity-array DoS (relationshipResolver.ts, collectionLoader.ts) — the relationships record is unbounded and type.field keyed, 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 pass O(relationships × entities) again (round 7 bounded only the per-entity rebuild). Capped at MAX_RELATIONSHIPS; the single-file entity-array paths now share the MAX_ENTITY_IDS ceiling the index path enforced, removing the entity-count multiplier.
  2. Uncapped settings field-options → DoS (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 at MAX_AVAILABLE_FIELDS, stopping the per-card walk early.
  3. Uncapped detail links → DoS (useCollection.ts) — card.detailUrls escaped MAX_MEDIA_PER_CARD (built from the uncapped detailUrls array plus one link per uncapped videos entry), so the sources overlay mounted an anchor + several URL parses per link with no windowing. Capped at MAX_DETAIL_LINKS_PER_CARD.
  4. Unbounded rank-placeholder / UI label → DoS (settingsLoader.ts, collection.schema.ts) — a forced rankPlaceholderText and the v2 uiLabels.rankPlaceholder are 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).
  5. Uncapped settings-import fields (settingsExport.schema.ts) — the import schema accepted an unbounded searchFields array and rankPlaceholderText, the same values the collection loader already caps, which persist globally and follow the visitor to later collections. Both bounded on import.
  6. Silent source loss on store-version bump (sourceStore.ts) — the store declares a persist version (already bumped 1→2→3) but no migrate, so a version mismatch handed undefined to merge, which threw on .sources; the swallowed throw discarded every configured source on upgrade. Added a migrate (carrying the persisted state forward so the existing dedup/legacy-cleanup runs as the migration) and made merge undefined-safe.
  7. Platform & genre filters matched the wrong field (filterMatch.ts, FilterChips.tsx, CardGrid.tsx) — the Platform filter offered short-title options but matched the full categoryTitle (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 same categoryShort field the filter matches on. (Correctness, not security.)
  8. Replace-import re-armed collection defaults (settingsExport.ts, settingsStore.ts) — the round-7/8 fix preserved hasAppliedCollectionDefaults but not appliedCollectionDefaultsSourceId, the marker applyCollectionSettings compares against the active source, so the active collection's settings.json defaults re-armed to clobber the just-imported values on the next load. Both markers are now snapshotted and restored (adding the missing setAppliedCollectionDefaultsSourceId action).

Considered and rejected (not fixed)

  • tsc -b-emitted vite.config.js shadowing vite.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.
  • Committed Playwright 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=1 substring collision — latent: no preset query param exists or is planned, and the documented reset clears only local display settings.
  • Imported-collection localStorage write — self-inflicted, deliberate-action-gated dead code; the quota-DoS reframing is unsubstantiated.
  • Provider .. 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.
  • Snap-ranking mixed number/string badge values — real but low-likelihood, with graceful categorical degradation and the coincident value still winnable.

Verification

npm run typecheck, npm run lint (0 errors), npm run test (1012 pass), and npm run build all pass locally. npm audit --omit=dev reports 0 advisories in the production tree. Each behaviour change has a reproduction test observed failing on main and passing on this branch.

REPPL added 11 commits August 13, 2026 18:28
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
@REPPL
REPPL merged commit 2b750e5 into main Aug 13, 2026
6 checks passed
@REPPL
REPPL deleted the security-hunt/round-9 branch August 13, 2026 19:07
@REPPL REPPL mentioned this pull request Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant