Skip to content

Security hardening — round 6: 7 confirmed defects - #16

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

Security hardening — round 6: 7 confirmed defects#16
REPPL merged 9 commits into
mainfrom
security-hunt/round-6

Conversation

@REPPL

@REPPL REPPL commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Round 6 of the autonomous security-hardening loop (state issue #9). Seven confirmed defects fixed, each surviving an independent adversarial refuter and covered by a reproduction test observed failing before and passing after. Baseline was fully green before any change; the suite is 942 passing (+19 new).

Fixed

  1. Collection forced settings permanently overwrote the user's global settings (src/stores/settingsStore.ts, src/context/CollectionDataContext.tsx). A remote settings.json's forced block was written into the user's persisted global settings with no backup, and the only teardown action had zero callers. Viewing one hostile allowlisted-CDN source once — reachable via a single unconfirmed /gh/<user>/ link that auto-adds and activates the source — rewrote the visitor's display config across reloads and source switches, several keys unrecoverable without a full reset. Now snapshots the user's own value per forced key (source-scoped, so a refetch cannot clobber it), restores on source change via a dedicated effect (a source with no settings.json never reaches the apply path), and rolls back on rehydration for crash recovery — mirroring _mechanicOverridesBackup. The dead clearCollectionForcedSettings is repurposed into restoreCollectionForcedSettings.

  2. Object-typed entity fields crashed the whole collection view (src/hooks/useCollection.ts). summary and the resolved platform title/shortTitle/summary/year were cast, not coerced. The .loose() schema lets an object through, which is truthy and reaches JSX as a child, so React throws "Objects are not valid as a React child" — the device-badge sink fires on first paint in the default grid. Coerced with a shared helper, matching the existing title/year/videos guards.

  3. collectionStats min/max spread crashed on large collections (src/utils/collectionStats.ts). Math.min(...values)/Math.max(...values) over a per-card array throws RangeError past the engine argument limit (~125k), and CollectionToast renders outside the collection error boundary, so the app blanks. Replaced with a single-pass loop.

  4. Uncapped entity-type fan-out (src/loaders/collectionLoader.ts). loadCollection mapped the untrusted entityTypes record into a Promise.all, each type fanning out into several probe fetches — a collection with tens of thousands of empty types amplified one load into ~80k CDN requests plus GitHub-quota exhaustion. Capped and pooled, always retaining the primary type. (The round-5 caps only covered the per-directory id path.)

  5. Uncapped discovery fan-out (src/hooks/useMyPlausibleMeDiscovery.ts). Discovery ran a Promise.all over every collection.json in the GitHub tree; the username is attacker-controlled and discovery auto-runs on the startup picker with no click, so a /gh/<user>/ link to a repo listing thousands of collections turned one page load into a CDN flood. Capped the count and pooled the metadata fetches.

  6. Unbounded media per card (src/hooks/useCollection.ts). card.imageUrls (images + videos) was uncapped and feeds the gallery dot buttons and the load-time preloader, so a hostile entity could mount tens of thousands of nodes / requests. Capped the combined list at the data layer.

  7. Hard reset silently left data behind or hung (src/lib/clearPersistedData.ts, src/db/index.ts). The three IndexedDB cleanups ran sequentially and only the first (deleteDB) was failure-intolerant, and deleteDB had no onblocked handler. A blocked or failed app-DB delete either hung the reset dialog or aborted the remaining steps, leaving cached collections and the plugin DB on disk while the UI reported "delete everything" complete. Added an onblocked handler and ran the cleanups independently via Promise.allSettled.

Considered and rejected (refuted)

  • applyMechanicOverrides second-activation backup clobber — unreachable: the Start Game overlay is gated on !activeMechanic, so overrides only apply when no mechanic is active, and every deactivation restores the backup first.
  • Write-only collectionForcedSettings mid-session revert — the claimed spontaneous-revert mechanism is false: TanStack Query's structural sharing keeps data.settings identity stable across refetches, so the apply effect does not re-fire. Folded into fix 1 (wire up / remove the dead state).

Deferred (nitpick / out of scope)

TruffleHog pre-commit --since-commit HEAD empty-range scan; gitleaks checksum not provenance-anchored; build:analyse writing stats.html into the deploy root; forced cardBackStyle/titleDisplayMode enum mismatch vs the store types; getDB() in-flight dedup (the root cause that makes the delete-blocked path reachable). No dependency changes (0 npm-audit findings on production deps).

Verification

npm run build, npm run typecheck, npm run lint (0 errors), and npm test (942 passing, +19 new) all green.

claude added 9 commits August 6, 2026 11:53
A collection's `forced` settings from a remote settings.json were written
straight into the user's persisted global settings with no backup, and the
only teardown action (clearCollectionForcedSettings) had zero callers. Viewing
one hostile allowlisted-CDN source once — reachable via a single unconfirmed
/gh/<user>/ link that auto-adds and activates the source — permanently
overwrote the visitor's display config (title mapping, badges, card face)
across reloads and source switches, with several keys unrecoverable except a
full settings reset.

Snapshot the user's own value for each key a source forces (source-scoped, so
a refetch of the same source cannot clobber the backup with already-forced
values), restore it when the active source changes, and roll it back on
rehydration for crash recovery — mirroring the _mechanicOverridesBackup
pattern. The restore runs from a dedicated effect on source change because a
source with no settings.json never reaches applyCollectionSettings. The dead
clearCollectionForcedSettings action is repurposed into
restoreCollectionForcedSettings.

Assisted-by: Claude:claude-fable-5
Two untrusted-input hardenings in the entity-to-DisplayCard transform:

- summary and the resolved platform title/shortTitle/summary/year were cast to
  string instead of coerced. The v2 entity schema is .loose(), so an
  object-typed value (e.g. an i18n {en: "..."} field) passed through truthy and
  reached JSX as a child, where React throws "Objects are not valid as a React
  child" and the whole collection view fails — the device badge sink fires on
  first paint in the default grid. Coerce these fields with a shared helper,
  matching the existing title/year/videos guards.

- card.imageUrls (images + videos) was unbounded. It feeds the gallery (one dot
  button per entry) and the load-time preloader (one cache probe + fetch per
  entry), so a hostile entity listing a huge media array could freeze the tab
  or flood the CDN. Cap the combined list.

Assisted-by: Claude:claude-fable-5
computeCollectionStats spread a per-card value array into Math.min(...values)
and Math.max(...values). The array length scales with the untrusted card
count, so a large collection throws RangeError past the engine's argument
limit (~125k on V8, lower on JSC). CollectionToast renders these stats outside
the collection error boundary, so the throw unmounts the app and blanks the
page. Replace the spread with a single-pass loop.

Assisted-by: Claude:claude-fable-5
loadCollection mapped the untrusted entityTypes record straight into a
Promise.all, and each type fans out into several probe fetches (index files,
GitHub discovery, single-file fallbacks). A collection.json listing tens of
thousands of empty entity types amplified one load into ~80k CDN requests plus
GitHub-quota exhaustion. Cap the type count and load the types through the same
fixed-size pool used for entity ids, always retaining the primary type.

Assisted-by: Claude:claude-fable-5
useMyPlausibleMeDiscovery ran a Promise.all over every collection.json in the
GitHub tree, one metadata fetch each. The username is attacker-controlled and
discovery auto-runs on the startup picker with no click, so a /gh/<user>/ link
to a repository listing thousands of collection.json files turned one page load
into a CDN request flood. Cap the discovered-collection count and fetch
metadata through a fixed-size pool.

Assisted-by: Claude:claude-fable-5
clearAllPersistedData awaited deleteDB first and sequentially, and deleteDB had
no onblocked handler. A blocked or failed app-DB delete therefore either hung
the reset dialog or aborted the remaining steps, silently leaving the
cached-collection store and the plugin cache DB on disk while the UI reported a
complete "delete everything". Give deleteDB an onblocked handler so it settles,
and run the three IndexedDB cleanups independently via Promise.allSettled.

Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-fable-5
The forced-settings snapshot was guarded solely by a source-id change, so a
later settings.json revision of the SAME source that began forcing an
additional key never captured that key's original user value — permanently
losing it on restore, the exact defect the backup is meant to prevent (a
routine forced-key addition by a benign author clobbers it for returning
users too). Snapshot per forced key instead: start a fresh backup on a genuine
source change, extend the existing backup on a same-source refetch, and back
up only keys not already held so an already-forced value never overwrites the
user's own original.

Assisted-by: Claude:claude-fable-5
The per-key snapshot helper used a single-use type parameter, which
@typescript-eslint/no-unnecessary-type-parameters rejects as an error and
failed the lint gate. Replace it with explicit per-key literal assignments,
which keep each backup value type correlated with its key without a generic.
Behaviour is unchanged; all 944 tests pass.

Assisted-by: Claude:claude-fable-5
@REPPL
REPPL merged commit 03a9565 into main Aug 6, 2026
6 checks passed
@REPPL
REPPL deleted the security-hunt/round-6 branch August 6, 2026 12:21
@REPPL REPPL mentioned this pull request Aug 6, 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.

2 participants