Security hardening — round 11: 9 confirmed defects - #21
Merged
Conversation
loadEntitiesFromDirectory deduped file names via dedupeIds but returned the loaded entities without dedupeEntitiesById, so two distinct index entries whose files declare the same inner id produced duplicate card ids: duplicate React keys, linked flips, cross-card edit bleed, and a reachable throw in CardGrid's selection guard. Route the directory path's return through the same dedupe the single-file paths use, and correct the dedupeEntitiesById docblock, which claimed dedupeIds already gave the index path this guarantee (it only dedupes fetch paths). Regression test observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
Entity schemas are loose and unknown keys are copied verbatim onto the display card, so a collection shipping a non-string myVerdict reached the edit form through an unchecked cast: the textarea showed '[object Object]' and save validation failed on a field whose error is never rendered, leaving the Save button silently dead. Coerce the incoming value (string passes, number becomes its string form, anything else is treated as absent) and render the missing summary/verdict error spans so a future validation failure is always visible. Reproduction tests observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
The Auto-Advance Rounds toggle was stored and surfaced in settings but never read: RoundResultOverlay unconditionally scheduled the next round two seconds after the result appeared, so turning the setting off changed nothing. Gate only the timer on the setting; click and keypress dismissal stay unconditional, so disabling auto-advance cannot soft-lock the game. Regression test observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
…anned username A collection scan for one username could finish after a newer scan for another and overwrite its results (or wipe them with a stale error), because no state update in useMyPlausibleMeDiscovery was guarded against staleness and the effect cleanup only cleared the debounce timer. Worst case, CollectionPicker paired the current username with a stale entry's folder and persisted an activated source that can never resolve. Add a generation token bumped on each run and on effect cleanup, guard every state update and the metadata worker loop, carry the scanned username on each discovered entry, and have the picker add sources under the entry's own username. Reproduction tests observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
The applied-defaults marker was a single slot holding only the last source, so alternating between two collections that both ship a settings.json defaults block re-applied each one's defaults over the user's manual choices on every return visit — contradicting the documented one-shot-per-source intent. Track applied source ids as a bounded FIFO array (mirroring the cache-consent pattern), gate on membership, migrate the persisted scalar (version 27 to 28), normalise tampered persisted values, and keep the round-6 rule that restoring forced settings never clears defaults tracking. Reproduction tests observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
…hema checkSourceHealth parsed fetched collection.json with the legacy items/categories schema and matched schema versions against strings the version detector never returns, so every healthy source showed a permanent red Invalid indicator in the sources settings tab. Parse with the v2 collection definition schema, take the name from the definition, infer the schema version via detectSchemaVersion, and stop deriving itemCount (entity counts live in per-type index files). The test fixture now imports the canonical example collection so it cannot go vacuous again, and the legacy payload is asserted to be rejected. Regression test observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
The answer shuffle seeded a position formula with a sum of the question id's char codes; real ids cluster into few residues and the hand-rolled swap loop is a pure function of seed mod 1000, so the correct answer landed at option B about 36% of the time and only 13 of 24 orderings ever occurred. Hash the id (djb2) and delegate to the existing seeded Fisher-Yates in utils/shuffle, keeping per-question determinism. Separately, getResults assumed the full streak bonus on every question, but scoring uses the pre-answer streak, so question i can earn at most min(i * streakBonus, maxStreakBonus) and a flawless five-question untimed run displayed 70%. Compute the achievable maximum per index; flawless runs now score exactly 100% at every length in both modes. Reproduction tests observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
Expert/Extreme distractor selection scored every available card and walked every key of the correct card; both dimensions are controlled by untrusted collection data, so a wide hostile collection froze the main thread for seconds per quiz start. Sample the scored candidate pool at MAX_SIMILARITY_CANDIDATES and cap the per-card key walk at MAX_SIMILARITY_KEYS, leaving behaviour for ordinary collections unchanged. Reproduction tests observed failing before the change and passing after. Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-fable-5
The fixture's three similar cards filled only half of the six-strong candidate pool, so the final unseeded shuffle could drop all of them (one run in twenty) and fail the at-least-one assertion. Six similar cards now fill the pool entirely, so every selected distractor must be similar and the assertion is deterministic. Verified over ten consecutive runs. 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 11 of the automated security-hardening loop (state: #9). Five parallel hunters swept loaders/schemas, hooks/stores, components, mechanics/plugins, and scripts/build/CI; 15 substantive candidates went through independent per-finding adversarial refutation; 9 survived and are fixed here, each with a reproduction test observed failing before the change and passing after. 46 tests added (1028 → 1074). Full gates green locally: typecheck, lint, tests, build.
Fixed (9 confirmed substantive)
src/loaders/collectionLoader.ts:435.loadEntitiesFromDirectorydeduped file names but not the ids declared inside the files, so distinct index entries sharing an inner id produced duplicate card ids: duplicate React keys, linked flips, cross-card edit bleed, and a reachable throw inCardGrid's selection guard. The directory path now sharesdedupeEntitiesByIdwith the single-file paths; the docblock that claimed otherwise is corrected.src/services/sourceHealthCheck.ts:8,76,175. It parsed fetchedcollection.jsonwith the legacy v1 items/categories schema and matched versions against"2.0"/"2", which the detector never returns — every healthy source showed a permanent red "Invalid" badge in the live sources tab. Now parsescollectionDefinitionSchema, infers the version viadetectSchemaVersion, and the test fixture imports the canonical example so the test cannot go vacuous again.src/mechanics/quiz/store.ts:371-393. The shuffle seed was a char-code sum of the question id; measured, the correct answer landed at option B ~36% of the time and only 13 of 24 orderings ever occurred. Now djb2-hashes the id and delegates to the existing seeded Fisher–Yates, preserving per-question determinism (distribution verified ~25% per position).src/mechanics/quiz/store.ts:348-361.maxScoreassumed the full streak bonus on every question, but scoring uses the pre-answer streak; a flawless 5-question untimed run displayed 70%. The maximum is now the per-index achievable sum; flawless runs score exactly 100% at every length in both timer modes.src/mechanics/quiz/generators/utils.ts:100-128. Similarity scoring walked every card × every key of the correct card (~8s synchronous from a ~508KB gzipped payload at 10k cards × 500 keys). Candidate pool now sampled atMAX_SIMILARITY_CANDIDATES, key walk capped atMAX_SIMILARITY_KEYS; behaviour for ordinary collections unchanged.src/mechanics/competing/components.tsx. The setting was stored and surfaced but never read; the round-result overlay advanced unconditionally after 2s. The timer is now gated on the setting; click/keypress dismissal stays unconditional so disabling it cannot soft-lock the game.src/hooks/useMyPlausibleMeDiscovery.ts,src/components/CollectionPicker/CollectionPicker.tsx. A slow scan for one username could land after a newer scan and overwrite its results, and the picker paired the current username with a stale entry's folder, persisting an activated permanently-404ing source. Generation token bumped per run and on effect cleanup guards every state update; each discovered entry now carries its scanned username, which the picker uses.src/stores/settingsStore.ts:546,1207. The applied-defaults marker was a single slot holding only the last source, contradicting the documented one-shot-per-source intent. Now a bounded FIFO array of source ids (persist version 27→28 with migration of the scalar and normalisation of tampered values); the round-6 rule that forced-settings restore never clears defaults tracking is preserved.myVerdictmade EditForm's Save silently no-op —src/components/EditForm/EditForm.tsx:85. An unchecked cast let a loose-schema object reach the form; validation failed on a field whose error was never rendered, so Save simply looked dead. The value is coerced at the form boundary and the missing summary/verdict error spans are rendered. (Round 10 logged this as a nitpick; this round's refuter confirmed it substantive on the invisible-error grounds.)Confirmed substantive, deferred (owner policy call)
package.json:28-39points atdist/assets/*, which stopped receiving app bundles when the app moved todist/demo/at the landing-page split; additionallysize:checkuses--check, which size-limit v11 does not have (dead since introduction). Repairing the paths makes both budgets fail immediately: measured ~265KB gzip JS vs the 200KB budget and ~41KB gzip CSS vs 15KB (one CSS file alone is 27KB gzip). A functional repair therefore forces either a budget re-baseline or a per-entry restructure targeting the initial-load chunks — a policy decision deferred to the owner. Suggested shape: repoint the two paths todist/demo/assets/*, replacesize:checkwith plainsize-limit, and re-baseline or switch to per-entry budgets onindex-*.js+react-vendor-*.js+ main CSS.Considered and rejected (refuted by independent adversarial review)
image-cachemissingcacheableResponse— the route's extension-anchored regex can never match a cross-origin URL under Workbox'sRegExpRouterules (cross-origin regex matches must start at index 0), so the route is inert for CDN images and the proposed fix a no-op; real image hosts are already covered by the anchored jsdelivr/raw/picsum routes, all of which allow opaque responses.formatFieldValuedeep-recursion crash — unreachable:stripUnsafeKeysrecurses the same payload shape ~23% shallower on the ingest path at every stack size, and every ingest call site catches theRangeError, so deep payloads die safely at load. Only a value-size render hang behind two clicks survives, as a nitpick.Nitpicks / defence-in-depth (noted, not fixed)
Theme JSON absent from the PWA precache glob (offline theme browser shows an error; the feature's fetched values are never applied anyway);
z.url()accepting non-http schemes into inert image-element sinks and the case-sensitive scheme check ingetImageUrls; un-anchored YouTube URL patterns (content substitution only — embed ids are re-derived);fieldDiscoveryprototype-chain lookups (unmounted provider);$-pattern splicing in provider URL templates (origin-pinned);gitlab.comallowlisted but absent from CSPconnect-src, plus dead allowlist helpers; quiz/snap-ranking redundant per-question rescans and index-keyed duplicate button keys; the latent Competing missing-value tie branch that never reschedules collection;_editedAtspoofing from loose entities ("Edited Invalid Date");build-site.mjsprototype-key content blocks and its attribute-less CSP-hash regex;check-pii.shunescaped allowlist dots and missing mp4/woff2/webp binary skips; tracked Playwrighttest-results/artefacts; lint covering onlysrc/; and the re-raised import-collection dead write (rounds 7/9/10) andvite.config.jsshadowing (round 9), both left per prior adjudication.Dependency review (report only): lockfile v3, every package resolves to registry.npmjs.org, no git/http deps, install scripts limited to esbuild/fsevents; no dependency changed. CI workflows are clean: actions SHA-pinned,
persist-credentials: false, nopull_request_target, no untrusted interpolation.The plugin subsystem was re-verified as dormant (only a type-only import reaches it from outside
src/plugins/).