Security hardening — round 7: 11 confirmed defects - #17
Merged
Conversation
A collection's settings.json is untrusted, and both keys below are written into the persisted global settings, so an out-of-range value outlives the collection that supplied it and follows the visitor to every later one. Neither is a forced setting, so the collection-forced backup does not restore them. maxVisibleCards tested its lower bound before flooring, so a fractional value such as 0.5 passed and stored 0, and no finiteness check meant 1e400 stored Infinity (which serialises to null and bricks flipping after the next reload). At either value CardGrid discards every card on flip, disabling the app's core interaction. Bound after the floor and cap at the settings-panel maximum, matching the positive-integer bound the config, settings-export and collection schemas already enforce. searchFields was type-filtered but uncapped, and search resolves every field on every card for each settled query, so an oversized list freezes the tab. 6 reproduction tests, 4 observed failing before the change. Assisted-by: Claude:claude-opus-5
Four defects reachable from an allowlisted-CDN collection, each confirmed by an independent adversarial reviewer and covered by a reproduction test observed failing before the change. DetailLink source and label were never type-checked: the entity schema is loose and the normalisation choke point validated only the URL, so a non-string source threw while the sources overlay lowercased it, and an object-valued source or label reached JSX as a child. Either replaced the whole card grid with the error boundary after two ordinary clicks. Both fields are now coerced where the URL is already sanitised. Duplicate entity ids survived the loader, defeating the random-selection guard that proves every selected id still exists by comparing counts: a duplicate makes the count match while an id is missing, throwing during render. They also produced duplicate React keys in every view and made one flip toggle several cards. Ids and single-file entity arrays are now deduplicated at load, which also drops the redundant fetch per repeat. Quiz wrong-answer selection scanned the correct-answer array linearly for every candidate value. Both dimensions are sized by the collection, so the product froze the main thread for minutes; the emitted question also carried one rendered option per alternative. Matched against a set and capped the offered alternatives, in both affected generators. Snap Ranking built its card-value map as a plain object literal, so a card id of "__proto__" silently stored nothing and the undefined guard read back Object.prototype — the card was dealt but could never be scored. Assisted-by: Claude:claude-opus-5
Five further defects reachable from an untrusted collection, each confirmed by an independent adversarial reviewer and covered by a reproduction test observed failing before the change. The relationship resolver rebuilt the whole relationship record for every entity, in both the resolve and the rank pass. Neither the record nor the entity list is capped, so cost grew with the square of the payload: 10000 relationships over 1000 entities took 11.9s, now 44ms. Relationships are grouped by entity type once when the resolver context is built. Image preloading had no aggregate cap and probed the cache one URL at a time, emitting its first progress tick only after the whole probe loop. The loading overlay clears only at 100% with no skip, and the active source persists, so a large collection left the app stuck behind the overlay across reloads. The list is capped and the cached set read once. Storing an image recomputed the cache totals with a full store scan, so caching N images cost N(N+1)/2 record reads and eviction added a second scan. Totals are now adjusted inside the transaction that stores or evicts the image, which also keeps them correct under the concurrent preload writes a bare increment would lose. 800 stores: 5.0s to under 0.4s. The filter dropdown mounts one checkbox per option and re-reconciles the list on every toggle. Genres are an uncapped per-entity array, so the option list had no ceiling; it is capped where the list is assembled, since many entities with a few genres each reach the same total. A stored "never cache" preference only suppressed the consent dialog while preloading still fetched and persisted every image, leaving it weaker than declining once. Caching permission is now a separate rule from whether to ask. Assisted-by: Claude:claude-opus-5
Assisted-by: Claude:claude-opus-5
Addresses the blocking findings from both pre-merge reviews. The eviction admission check still read the cache totals in a snapshot before the write transaction opened, so the five concurrent writes the preloader issues each concluded independently that the image still fitted. A reviewer reproduced both failure modes: the budget overshot to 140% of its maximum, and a burst of concurrent evictions sharing one stale baseline evicted far past the target. Admission, eviction and the write now happen in a single transaction over both stores, which IndexedDB serialises, so each caller sees the previous one's committed state. Reading the totals also falls back to a full scan when the stored record is missing or has drifted negative, restoring the self-healing the per-write rescan gave. The filter-option ceiling covered only genres, leaving platform and year with a ten-thousand-option ceiling through the same unvirtualised sink; all three are now capped where the lists are assembled. Three behaviour changes shipped without a reproduction test, contradicting the round's own claim: the preload cap, the fill-the-blank alternative cap, and the consent gate itself. Each now has one, observed failing first. The previous commit also carried around 580 lines of unrelated reformatting in CardGrid and LoadingScreen, burying a one-line change and leaving both files inconsistent with their unformatted neighbours. Both are restored to their original formatting with only the substantive edits. Assisted-by: Claude:claude-opus-5
Refresh the test count, the filter-cap scope and the image-cache entry to match what shipped after the pre-merge review, and log the reviewer's remaining eviction-metadata finding for a future round. Assisted-by: Claude:claude-opus-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 7 of the autonomous security-hardening loop (state: #9).
Six parallel hunters swept the tracked tree; every candidate was then challenged by an independent adversarial reviewer whose explicit job was to refute it. Only findings that survived refutation are fixed here. Each behaviour change has a reproduction test observed failing before the change and passing after.
Baseline before any change:
npm ci, typecheck, lint (0 errors), build, and 944 tests all green. Now 985 tests (+41).Confirmed and fixed
settings.jsonmaxVisibleCardscould disable card flipping. The bound was tested before the floor and there was no finiteness check, so0.5stored0and1e400storedInfinity(serialising tonull). Either makesCardGriddiscard every card on flip. The value persists globally and is adefaultskey, so the round-6 forced-settings restore does not cover it — it follows the visitor to every later collection. This was the onlymaxVisibleCardsingress with no upper bound and no lower bound applied after flooring; the cap of 10 matches the settings-panel stepper's own ceiling.src/loaders/settingsLoader.ts:229settings.jsonsearchFieldswas uncapped. Type-filtered but unbounded, and search resolves every field on every card per settled query. Measured: 5 000 fields (43 KiB) against a 500-card collection costs 1.6 s per search versus 5.7 ms at the 3-field default. Also persisted globally with no UI to edit it back.src/loaders/settingsLoader.ts:268Non-string
DetailLink.source/labelcrashed the card grid. The entity schema is loose and the normalisation choke point validated only the URL. A numericsourcethrows where the sources overlay lowercases it; an object-valuedsource/labelreaches JSX as a child. Either replaces the whole grid with the error boundary, two ordinary clicks after load.src/types/links.ts:74Duplicate entity ids caused a render-time throw.
CardGrid's random-selection guard proves "every selected id still exists" by comparing counts, which only holds if ids are unique — a duplicate lets the count match while an id is absent. Duplicates also produced duplicate React keys in every view and made one flip toggle several cards. Fixed at the root: ids and single-file entity arrays are deduplicated at load, which also drops the redundant fetch per repeat.src/loaders/collectionLoader.ts:113Quiz generation froze the tab. Wrong-answer selection scanned the correct-answer array linearly for every candidate value; both dimensions are collection-sized. Measured through the real default path: 1.45 MB → 2.0 s, 3.67 MB → 16.6 s, 7.37 MB → 168 s of frozen main thread, against under 200 ms to load and validate. The emitted question also carried one rendered option per alternative (399 options measured in the fill-the-blank case). Both affected generators are fixed and tested.
relationshipToName.ts:256,fillTheBlank.ts:144A card id of
"__proto__"was unscoreable in Snap Ranking. Assigning a primitive through the inherited__proto__setter is a silent no-op, so the card was dealt but its value never stored, and theundefinedguard read backObject.prototype. Perfect play scored 20 against a displayed maximum of 30, and the card rendered as[object Object].src/mechanics/snap-ranking/store.ts:145Relationship resolution was quadratic in payload size. The whole relationship record was rebuilt per entity in both the resolve and the rank pass, with neither the record nor the entity list capped. Measured 10 000 relationships × 1 000 entities: 11.9 s before, 44 ms after. Relationships are grouped by entity type once when the context is built.
src/loaders/relationshipResolver.ts:60Image preloading could lock the app behind the loading overlay. No aggregate cap, and the cache was probed one URL at a time with the first progress tick emitted only after the whole loop. The overlay clears only at 100% with no skip, and the active source persists, so a reload re-enters the same state.
src/services/imageCache.ts:534Image caching was quadratic, and its budget check was not concurrency-safe. Storing an image recomputed the totals with a full store scan, so caching N images cost N(N+1)/2 record reads. Totals are now maintained incrementally — and, after the first review round, admission, eviction and the write happen in a single transaction over both stores: previously the "do I need to evict" check read a snapshot taken before the write transaction opened, so the preloader's five concurrent writes each concluded independently that the image still fitted (reproduced: budget overshot to 140%, and concurrent evictions sharing one stale baseline over-evicted). Reading the totals falls back to a full scan if the stored record is missing or has drifted negative, so a bad record cannot permanently disable eviction. 800 stores: 5.0 s to under 0.4 s.
src/services/imageCache.ts:181The filter dropdown had no option ceiling. It mounts one checkbox per option and re-reconciles the list on every toggle.
genresis an uncapped per-entity array, and platform/year were bounded only by the 10 000-entity loader cap — far beyond anything usable as a filter. All three fields are capped where the lists are assembled, since many entities contributing a few values each reach the same total.src/components/CardGrid/CardGrid.tsx:614"Never cache" did not stop image caching. The stored preference only suppressed the consent prompt; preloading still fetched every image from the third-party host and wrote it to IndexedDB, making "Never cache" strictly weaker than declining once. Caching permission is now a rule separate from whether to ask.
src/components/LoadingScreen/LoadingScreen.tsx:155Review history
Both pre-merge reviewers returned NO-SHIP on the first pass, and one remediation round addressed every blocking finding: the non-atomic eviction admission check (9), the filter cap covering only one of three fields (10), three behaviour changes shipping without a reproduction test (8, 5-fillTheBlank, 11 — each now has one, observed failing first), and ~580 lines of unrelated reformatting bundled into a security commit (both files restored to their original formatting with only the substantive edits;
CardGrid.tsxis now a 13-line diff).Confirmed but deferred
title/summary/myVerdictonto another's cards, flags them as edited, and silently overwrites the first collection's note if the colliding card is then edited. Collision is routine rather than adversarial — the project's own tutorial mintsitem-1/books, and context edits key on ids likecontext:snes. Refuted as a security finding (no exfiltration path: the plugingetCardsbridge is unwired and the sandbox blocks network), but it contradicts a written requirement in R-020. Every correct fix changes the persisted shape of a store that has noversionormigrate, and the migration needs a product decision about provenance-less existing edits. Deferred to a dedicated, human-reviewed round.Considered and rejected
cardBackStyle/titleDisplayModeenum mismatch, re-raised as substantive. The mismatch is real but the claimed impact is not: the--card-title-*custom properties are defined and never read (titles hard-code their layout), andcardBackStylehas no renderer or UI control at all. Round 6's backup/restore also reverts the value on source change. Remains the round-6 nitpick...traversal (escapes only a UX template), un-gated health/update/manifest fetches (CSP-neutralised), and the dormantsrc/plugins/**subsystem.Known gaps, not addressed here
"Never cache"is enforced at the loading-screen call site, not insidepreloadImages/imageCache.set. The explicit "Preload images" button in storage settings is a deliberate user action, anduseCachedImagehas no callers today — but the rule would be more robust at the boundary.maxVisibleCardsandsearchFieldsclamps are silent.Dependency review (report only)
npm audit --omit=devreports 0 advisories in the production tree. All 25 advisories are devDependency-only, concentrated in dev-server and build tooling. No dependency,package.json, or lockfile change was made. Worth a maintainer's attention outside this loop: theviteandstorybookdev-server advisories are drive-by exploitable whilenpm run dev/storybookis listening, and there is nodependabot.yml/renovate.jsonto keep the SHA-pinned actions and versions current.