Skip to content

Security hardening — round 8: 5 confirmed defects - #18

Merged
REPPL merged 6 commits into
mainfrom
security-hunt/round-8
Aug 13, 2026
Merged

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

Conversation

@REPPL

@REPPL REPPL commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Autonomous security-hardening round 8. Five parallel hunters swept the tracked source with a security-first lens; every candidate was put through an independent adversarial refuter, and only survivors were fixed. Five confirmed defects fixed, each root-cause with a reproduction test observed failing before the change and passing after. Two candidates were refuted and left unfixed. No dependency changes.

Test suite: 985 → 994 (9 new reproduction tests). build, typecheck, lint (0 errors) and the full suite pass locally.

Fixed (5 confirmed substantive)

  1. Discovered collection metadata could blank the whole app. The startup picker renders collection.json name/description/itemCount directly as React children and sits above every error boundary; fetchCollectionMetadata only checked that the parsed body was an object. An attacker /gh/USER/ discovery link (discovery auto-runs with no click) serving a non-primitive value threw "Objects are not valid as a React child" and unmounted the entire tree, and the bad name also persisted into the source store. Fixed by coercing all three to primitives at the discovery trust boundary. (src/hooks/useMyPlausibleMeDiscovery.ts)

  2. Uncapped per-entity DOM fan-out. getDisplayableFields and the platform additionalFields copy emitted one row per entity key with no ceiling; the entity schema is .loose(), so a single entity carrying ~100k scalar keys mounted a ~300k-node DOM subtree in one synchronous commit when its "More" overlay opened, freezing/OOM-killing the tab. Both paths capped at 100, matching the existing MAX_MEDIA_PER_CARD. (src/utils/entityFields.ts, src/hooks/useCollection.ts)

  3. Inverted forced-setting allowlists. validateForcedSettings guarded cardBackStyle and titleDisplayMode against allowlists (plain|pattern|gradient, always|hover|never) that matched neither the real CardBackStyle (bitmap|svg|colour) nor TitleDisplayMode (truncate|wrap) enums — so every honest author's forced value was silently dropped while out-of-enum values were accepted, persisted globally, and then rejected wholesale by the settings-export schema on reimport, leaving the user's own backup unrestorable. Allowlists corrected to the real enums. (This fixes the round-6/7 mismatch on its genuine grounds — the correctness drop and the export-brick self-DoS — not the previously-refuted CSS-clamping claim, which remains inert.) (src/loaders/settingsLoader.ts)

  4. Replace-mode settings import re-armed by the active collection. A "replace" import called resetToDefaults(), clearing the one-time hasAppliedCollectionDefaults marker and re-arming the CollectionDataContext effect, so the active collection's defaults (including fieldMapping) immediately overwrote seven just-imported fields while the UI reported success. The marker is now preserved across the replace reset. (src/utils/settingsExport.ts)

  5. Imported edits could persistently crash the grid. The edits import schema typed field values as unknown and merged them raw over the source card, which is rendered as a React child; an "edits backup" with an object/array-valued field threw on every render and, because edits persist, bricked the collection view across reloads and other collections. Edit field values are now restricted to JSON primitives at the import boundary — the only shape the edit form produces. (src/utils/editExport.ts)

Refuted (considered, not fixed)

  • ?reset=1 settings wipe — a documented, in-app-surfaced self-service reset that clears only the itemdeck-settings key (edits/themes/sources/plugins survive); the substring match has no realistic colliding URL the app generates or accepts. Minor hardening (exact-param parse) noted, not bundled.
  • Unbounded collection.json maxVisibleCardsz.number().int().positive() already blocks the harmful non-finite / sub-1 / float inputs, and the sole consumer uses the value only as a downward cap min'd against the real card count, so a large value is inert rather than a DoS. Distinct from the round-7 settings.json fix; a .max() for schema consistency is a nitpick.

Dependency review (report only)

npm audit --omit=dev reports 0 advisories in the production tree. No dependency or lockfile change in this PR.

claude added 6 commits August 13, 2026 10:00
The startup collection picker renders discovered collection.json metadata
(name/description/itemCount) directly as React children, and the picker sits
above every error boundary. fetchCollectionMetadata only checked that the
parsed body was an object, so an untrusted /gh/<user>/ source (discovery
auto-runs with no click) serving a non-primitive value threw "Objects are not
valid as a React child" and blanked the whole app; the bad name also persisted
into the source store. Coerce name/description/itemCount to primitives at the
discovery trust boundary.

Assisted-by: Claude:claude-fable-5
getDisplayableFields and the platform additionalFields copy emitted one row per
entity key with no ceiling. The entity schema is loose, so a single untrusted
entity carrying a very large key set mounted an unbounded DOM subtree in one
synchronous commit when its "More" overlay opened, freezing or OOM-killing the
tab. Cap both paths at 100 fields, matching the existing per-card media cap.

Assisted-by: Claude:claude-fable-5
validateForcedSettings guarded these two forced settings against allowlists that
matched neither the real CardBackStyle (bitmap/svg/colour) nor TitleDisplayMode
(truncate/wrap) enums. Every legitimate forced value was silently dropped while
out-of-enum values were accepted, persisted into global settings, and then
rejected wholesale by the settings-export schema on reimport — leaving the
user's own backup unrestorable. Match the allowlists to the real enums.

Assisted-by: Claude:claude-fable-5
A "replace"-mode settings import called resetToDefaults(), which cleared the
one-time hasAppliedCollectionDefaults marker to false and re-armed the
CollectionDataContext effect. The active collection's defaults (including
fieldMapping) then immediately overwrote the just-imported values while the UI
reported success. Preserve the marker across the replace reset.

Assisted-by: Claude:claude-fable-5
The edits import schema typed field values as unknown and the values were
merged raw over the source card, which is rendered as a React child. An "edits
backup" with an object- or array-valued field (e.g. title) threw "Objects are
not valid as a React child" on every grid render and, because edits persist,
bricked the collection view across reloads and other collections. Restrict edit
field values to JSON primitives at the import boundary — the only shape the edit
form produces.

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