Skip to content

Security hardening — round 11: 9 confirmed defects - #21

Merged
REPPL merged 10 commits into
mainfrom
security-hunt/round-11
Aug 16, 2026
Merged

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

Conversation

@REPPL

@REPPL REPPL commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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)

  1. Entity id collisions survived the index-driven loader pathsrc/loaders/collectionLoader.ts:435. loadEntitiesFromDirectory deduped 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 in CardGrid's selection guard. The directory path now shares dedupeEntitiesById with the single-file paths; the docblock that claimed otherwise is corrected.
  2. Source health check validated against the wrong schemasrc/services/sourceHealthCheck.ts:8,76,175. It parsed fetched collection.json with 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 parses collectionDefinitionSchema, infers the version via detectSchemaVersion, and the test fixture imports the canonical example so the test cannot go vacuous again.
  3. Quiz answer order was strongly biasedsrc/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).
  4. A flawless quiz could never score 100%src/mechanics/quiz/store.ts:348-361. maxScore assumed 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.
  5. Expert/Extreme distractor selection was an uncapped scan over attacker-scaled datasrc/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 at MAX_SIMILARITY_CANDIDATES, key walk capped at MAX_SIMILARITY_KEYS; behaviour for ordinary collections unchanged.
  6. The Competing "Auto-Advance Rounds" toggle was inertsrc/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.
  7. Discovery scans raced and could persist a broken sourcesrc/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.
  8. Collection defaults re-applied over the user's choices on every A↔B alternationsrc/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.
  9. A non-string entity myVerdict made EditForm's Save silently no-opsrc/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)

  • The size-limit bundle gate is deadpackage.json:28-39 points at dist/assets/*, which stopped receiving app bundles when the app moved to dist/demo/ at the landing-page split; additionally size:check uses --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 to dist/demo/assets/*, replace size:check with plain size-limit, and re-baseline or switch to per-entry budgets on index-*.js + react-vendor-*.js + main CSS.

Considered and rejected (refuted by independent adversarial review)

  • PWA image-cache missing cacheableResponse — the route's extension-anchored regex can never match a cross-origin URL under Workbox's RegExpRoute rules (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.
  • formatFieldValue deep-recursion crash — unreachable: stripUnsafeKeys recurses the same payload shape ~23% shallower on the ingest path at every stack size, and every ingest call site catches the RangeError, 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 in getImageUrls; un-anchored YouTube URL patterns (content substitution only — embed ids are re-derived); fieldDiscovery prototype-chain lookups (unmounted provider); $-pattern splicing in provider URL templates (origin-pinned); gitlab.com allowlisted but absent from CSP connect-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; _editedAt spoofing from loose entities ("Edited Invalid Date"); build-site.mjs prototype-key content blocks and its attribute-less CSP-hash regex; check-pii.sh unescaped allowlist dots and missing mp4/woff2/webp binary skips; tracked Playwright test-results/ artefacts; lint covering only src/; and the re-raised import-collection dead write (rounds 7/9/10) and vite.config.js shadowing (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, no pull_request_target, no untrusted interpolation.

The plugin subsystem was re-verified as dormant (only a type-only import reaches it from outside src/plugins/).

claude added 10 commits August 16, 2026 03:25
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
@REPPL
REPPL merged commit b522616 into main Aug 16, 2026
6 checks passed
@REPPL
REPPL deleted the security-hunt/round-11 branch August 16, 2026 04:03
@REPPL REPPL mentioned this pull request Aug 16, 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