Security hardening — round 6: 7 confirmed defects - #16
Merged
Conversation
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
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.
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
Collection
forcedsettings permanently overwrote the user's global settings (src/stores/settingsStore.ts,src/context/CollectionDataContext.tsx). A remotesettings.json'sforcedblock 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 nosettings.jsonnever reaches the apply path), and rolls back on rehydration for crash recovery — mirroring_mechanicOverridesBackup. The deadclearCollectionForcedSettingsis repurposed intorestoreCollectionForcedSettings.Object-typed entity fields crashed the whole collection view (
src/hooks/useCollection.ts).summaryand the resolved platformtitle/shortTitle/summary/yearwere 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 existingtitle/year/videosguards.collectionStatsmin/max spread crashed on large collections (src/utils/collectionStats.ts).Math.min(...values)/Math.max(...values)over a per-card array throwsRangeErrorpast the engine argument limit (~125k), andCollectionToastrenders outside the collection error boundary, so the app blanks. Replaced with a single-pass loop.Uncapped entity-type fan-out (
src/loaders/collectionLoader.ts).loadCollectionmapped the untrustedentityTypesrecord into aPromise.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.)Uncapped discovery fan-out (
src/hooks/useMyPlausibleMeDiscovery.ts). Discovery ran aPromise.allover everycollection.jsonin 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.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.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, anddeleteDBhad noonblockedhandler. 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 anonblockedhandler and ran the cleanups independently viaPromise.allSettled.Considered and rejected (refuted)
applyMechanicOverridessecond-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.collectionForcedSettingsmid-session revert — the claimed spontaneous-revert mechanism is false: TanStack Query's structural sharing keepsdata.settingsidentity 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 HEADempty-range scan; gitleaks checksum not provenance-anchored;build:analysewritingstats.htmlinto the deploy root; forcedcardBackStyle/titleDisplayModeenum 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), andnpm test(942 passing, +19 new) all green.