Security hardening — round 10: 5 confirmed defects - #20
Merged
Conversation
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
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.
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)
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(v2cardFrontConfigSchema, a barez.string()with no.max()) and persisted forcedfieldMappingvalues (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. AMAX_FIELD_PATH_LENGTHguard at the sharedgetFieldValue/resolveFieldPathchokepoint now resolves an over-long path to the fallback in bounded time.Competing store prototype pollution. The Competing (Top Trumps) store built its
cardDatamap as a plain object literal keyed by untrusted entity ids. An id of__proto__(which survives the loader'sSet-keyed dedup and reachescard.id) re-pointed the map's prototype to the attacker card instead of creating an own key — the card was counted but omitted fromObject.keys, never dealt, and able to dropvalidCardCountbelow the four-card minimum. Now built withObject.create(null), mirroring the snap-ranking store's existing guard.Uncapped
verdictFieldsdisplay-config DoS. The collection-controlledverdictFieldsordering list (v2cardDisplayConfigSchema) wasz.array(z.string())with no bound, andgetDisplayableFieldsscans every spec against the entity's fields (two lowercase allocations per comparison) onCardExpandedmount — 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 theuiLabelstruncation pattern, and the scan hoists the per-spec lowercase out offind.Eager
CardExpandedmount in list/compact/fit.CardCompactItemandCardListItemmounted aCardExpandedper card unconditionally, defeating the lazy-mount guardCard.tsxdocuments (each instance registers a window resize listener viauseViewportSizeplus several store subscriptions). These layouts are not virtualised, so a large collection mounted thousands of listeners and subscriptions on view switch. Both renderers now mountCardExpandedlazily on first open.Hard Reset left service-worker caches on disk.
clearAllPersistedDatacleared 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.jsonand 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 andnpm run buildall pass.Deferred (confirmed substantive, own dedicated round)
runtimeCachingrules 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 (aninjectManifestconversion 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
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.myVerdictmakesEditFormSave silently no-op — confirmed but nitpick: behind the default-off edit-mode opt-in, no crash/DoS/exfil.fieldDiscovery,useCollectionManifest,useImageValidation,CollectionDataContext,fieldPathResolver— dead code (unmounted provider / zero consumers) or harmless today.javascript:-scheme gap — reaches only inert imagesrcsinks.Dependency review: no dependency was changed.