Skip to content

Security hardening — round 10: 5 confirmed defects - #20

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

Security hardening — round 10: 5 confirmed defects#20
REPPL merged 6 commits into
mainfrom
security-hunt/round-10

Conversation

@REPPL

@REPPL REPPL commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Autonomous security-hardening round 10. Parallel hunters swept the tracked tree with a security-hardening lens; every candidate was put to an independent adversarial refuter before any fix. Five confirmed substantive defects fixed, each with a reproduction test observed failing before and passing after.

Fixed (5 confirmed substantive)

  1. Quadratic field-path parser DoS. parsePath (src/loaders/fieldPath.ts) is O(n^2) in path length for a bracket-free path — it re-scans the remaining string for [ every iteration. display.card.front.title/subtitle/badge (v2 cardFrontConfigSchema, a bare z.string() with no .max()) and persisted forced fieldMapping values (settings.json) feed it uncapped untrusted strings, resolved once per card at grid render, so a ~2MB dotted path froze the main thread for tens of seconds (measured 14.3s for a single 2MB path) with no error boundary reached. A MAX_FIELD_PATH_LENGTH guard at the shared getFieldValue/resolveFieldPath chokepoint now resolves an over-long path to the fallback in bounded time.

  2. Competing store prototype pollution. The Competing (Top Trumps) store built its cardData map as a plain object literal keyed by untrusted entity ids. An id of __proto__ (which survives the loader's Set-keyed dedup and reaches card.id) re-pointed the map's prototype to the attacker card instead of creating an own key — the card was counted but omitted from Object.keys, never dealt, and able to drop validCardCount below the four-card minimum. Now built with Object.create(null), mirroring the snap-ranking store's existing guard.

  3. Uncapped verdictFields display-config DoS. The collection-controlled verdictFields ordering list (v2 cardDisplayConfigSchema) was z.array(z.string()) with no bound, and getDisplayableFields scans every spec against the entity's fields (two lowercase allocations per comparison) on CardExpanded mount — once per card in the non-virtualised list/compact layouts — so a 200k-entry array froze the tab on collection load (~34s at 200 cards). The schema now truncates the array (MAX_VERDICT_FIELDS) and each spec (MAX_VERDICT_FIELD_LENGTH), matching the uiLabels truncation pattern, and the scan hoists the per-spec lowercase out of find.

  4. Eager CardExpanded mount in list/compact/fit. CardCompactItem and CardListItem mounted a CardExpanded per card unconditionally, defeating the lazy-mount guard Card.tsx documents (each instance registers a window resize listener via useViewportSize plus several store subscriptions). These layouts are not virtualised, so a large collection mounted thousands of listeners and subscriptions on view switch. Both renderers now mount CardExpanded lazily on first open.

  5. Hard Reset left service-worker caches on disk. clearAllPersistedData cleared localStorage and three IndexedDB databases but never touched the service worker's Cache Storage buckets (jsdelivr-cache/github-raw-cache/image-cache, populated by vite-plugin-pwa) or unregistered the worker, so the viewed collection JSON, settings.json and imagery survived the reset and were served back on the reload — contradicting the dialog's "permanently delete all your ... cached data" promise. The reset now deletes every Cache Storage bucket and unregisters the service worker, guarded for environments lacking the APIs.

14 reproduction tests added (1014 -> 1028). npm run typecheck, npm run lint (0 errors), the full suite and npm run build all pass.

Deferred (confirmed substantive, own dedicated round)

  • Service-worker runtime caching ignores the "Never cache" consent choice. The runtimeCaching rules cache remote collection data and images into Cache Storage regardless of the user's consent — the app-layer gate only covers the IndexedDB caches. The correct fix is a consent-aware service worker (an injectManifest conversion or removing the redundant SW data/image cache layer), both moderate/high-regression build + SW architecture changes out of scope for a smallest-diff round. Fix 5 above already closes the reset direction of this gap.

Considered and rejected

  • Edits-import confirm() treats Escape/window-X as "replace all edits" — confirmed but nitpick: not attacker-reachable (user-driven file import only), and the destructive Cancel is documented by the dialog copy.
  • A non-string myVerdict makes EditForm Save silently no-op — confirmed but nitpick: behind the default-off edit-mode opt-in, no crash/DoS/exfil.
  • Inert "Import Collection" localStorage write — re-refuted (self-inflicted, deliberate-action-gated; prior rounds 7/9 reached the same conclusion).
  • Data-layer prototype-chain / un-gated-fetch reads in fieldDiscovery, useCollectionManifest, useImageValidation, CollectionDataContext, fieldPathResolver — dead code (unmounted provider / zero consumers) or harmless today.
  • Primary-image javascript:-scheme gap — reaches only inert image src sinks.

Dependency review: no dependency was changed.

claude added 6 commits August 14, 2026 18:32
The v1 field-path parser is quadratic in path length for a bracket-free
path (parsePath re-scans the remaining string for "[" every iteration).
display.card.front.title/subtitle/badge (cardFrontConfigSchema, a bare
z.string()) and persisted forced fieldMapping values feed it uncapped
untrusted strings, resolved once per card at grid render, so a ~2MB
dotted path froze the main thread for tens of seconds with no error
boundary reached.

Guard the shared getFieldValue/resolveFieldPath chokepoint with a
MAX_FIELD_PATH_LENGTH bound so an over-long path resolves to the fallback
in bounded time. Real paths are a handful of characters.

Assisted-by: Claude:claude-fable-5
The Competing store built its cardData map as a plain object literal
keyed by untrusted entity ids. An id of "__proto__" (which survives the
loader's Set-keyed dedup and reaches card.id) re-pointed the map's
prototype to the attacker card instead of creating an own key, so the
card was counted in config.cards but omitted from Object.keys — never
dealt, and able to drop validCardCount below the four-card minimum.

Build the map with Object.create(null), mirroring the snap-ranking
store's existing guard.

Assisted-by: Claude:claude-fable-5
The collection-controlled verdictFields ordering list was
z.array(z.string()) with no bound, and getDisplayableFields scans every
spec against the entity's fields (two lowercase allocations per
comparison) on CardExpanded mount — once per card in the non-virtualised
list/compact layouts — so a 200k-entry array froze the tab on collection
load.

Truncate the array and each spec in the schema (matching the uiLabels
truncation pattern, so one oversized value cannot deny the whole load),
and hoist the per-spec lowercase out of the find callback.

Assisted-by: Claude:claude-fable-5
CardCompactItem and CardListItem mounted a CardExpanded per card
unconditionally, defeating the lazy-mount guard Card.tsx documents: each
instance registers a window resize listener via useViewportSize plus
several store subscriptions. The list, compact and fit layouts are not
virtualised, so a large collection mounted thousands of listeners and
subscriptions on view switch.

Gate the CardExpanded mount behind a hasOpenedExpanded flag set on first
open, matching Card.tsx.

Assisted-by: Claude:claude-fable-5
The hard reset cleared localStorage and three IndexedDB databases but
never touched the service worker's Cache Storage buckets (populated by
vite-plugin-pwa: jsdelivr-cache, github-raw-cache, image-cache) or
unregistered the worker. The viewed collection JSON, settings.json and
imagery therefore survived the reset and were served back on the reload,
contradicting the dialog's "permanently delete all your ... cached data"
promise.

Delete every Cache Storage bucket and unregister the service worker as
part of clearAllPersistedData, guarded for environments lacking the APIs.

Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-fable-5
@REPPL
REPPL merged commit 59126f2 into main Aug 14, 2026
6 checks passed
@REPPL
REPPL deleted the security-hunt/round-10 branch August 14, 2026 18:40
@REPPL REPPL mentioned this pull request Aug 14, 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