Security hardening — round 8: 5 confirmed defects - #18
Merged
Conversation
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
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.
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)
Discovered collection metadata could blank the whole app. The startup picker renders
collection.jsonname/description/itemCountdirectly as React children and sits above every error boundary;fetchCollectionMetadataonly 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 badnamealso persisted into the source store. Fixed by coercing all three to primitives at the discovery trust boundary. (src/hooks/useMyPlausibleMeDiscovery.ts)Uncapped per-entity DOM fan-out.
getDisplayableFieldsand the platformadditionalFieldscopy 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 existingMAX_MEDIA_PER_CARD. (src/utils/entityFields.ts,src/hooks/useCollection.ts)Inverted forced-setting allowlists.
validateForcedSettingsguardedcardBackStyleandtitleDisplayModeagainst allowlists (plain|pattern|gradient,always|hover|never) that matched neither the realCardBackStyle(bitmap|svg|colour) norTitleDisplayMode(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)Replace-mode settings import re-armed by the active collection. A "replace" import called
resetToDefaults(), clearing the one-timehasAppliedCollectionDefaultsmarker and re-arming theCollectionDataContexteffect, so the active collection'sdefaults(includingfieldMapping) immediately overwrote seven just-imported fields while the UI reported success. The marker is now preserved across the replace reset. (src/utils/settingsExport.ts)Imported edits could persistently crash the grid. The edits import schema typed field values as
unknownand 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=1settings wipe — a documented, in-app-surfaced self-service reset that clears only theitemdeck-settingskey (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.collection.jsonmaxVisibleCards—z.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-7settings.jsonfix; a.max()for schema consistency is a nitpick.Dependency review (report only)
npm audit --omit=devreports 0 advisories in the production tree. No dependency or lockfile change in this PR.