From 0d48221476b2c3a31005f8fa6eaff6f95dfd398f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 23 Feb 2026 11:37:17 -0600 Subject: [PATCH] Validate encrypted settings with Zod to prevent corrupted data from crashing the app Encrypted settings synced via NIP-78 were parsed with a bare JSON.parse() and cast with 'as EncryptedSettings', allowing invalid data (e.g. theme as an object instead of a string) to pass through unchecked and crash the UI when applied to classList.add(). Now both useEncryptedSettings and useInitialSync validate decrypted JSON with a shared EncryptedSettingsSchema, gracefully dropping invalid fields instead of propagating them. --- src/components/AppProvider.tsx | 34 ++------------ src/hooks/useEncryptedSettings.ts | 11 ++++- src/hooks/useInitialSync.ts | 8 +++- src/lib/schemas.ts | 74 +++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 34 deletions(-) create mode 100644 src/lib/schemas.ts diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 9a7e64d0..4d910b74 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -3,6 +3,7 @@ import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; import { themes, buildThemeCss } from '@/themes'; +import { ThemeSchema, FeedSettingsSchema, ContentWarningPolicySchema } from '@/lib/schemas'; interface AppProviderProps { children: ReactNode; @@ -22,38 +23,9 @@ const RelayMetadataSchema = z.object({ updatedAt: z.number(), }) satisfies z.ZodType; -// Zod schema for FeedSettings validation. -// All fields use .optional() so localStorage data with missing keys -// (from older encrypted settings) doesn't reject the whole object. -// .passthrough() preserves extra keys from newer encrypted settings. -// Missing fields get filled in by the defaultConfig merge in line below. -const FeedSettingsSchema = z.object({ - feedIncludePosts: z.boolean().optional(), - feedIncludeReposts: z.boolean().optional(), - feedIncludeArticles: z.boolean().optional(), - showArticles: z.boolean().optional(), - showVines: z.boolean().optional(), - showPolls: z.boolean().optional(), - showTreasures: z.boolean().optional(), - showTreasureGeocaches: z.boolean().optional(), - showTreasureFoundLogs: z.boolean().optional(), - showColors: z.boolean().optional(), - showPacks: z.boolean().optional(), - showStreams: z.boolean().optional(), - feedIncludeVines: z.boolean().optional(), - feedIncludePolls: z.boolean().optional(), - feedIncludeTreasureGeocaches: z.boolean().optional(), - feedIncludeTreasureFoundLogs: z.boolean().optional(), - feedIncludeColors: z.boolean().optional(), - feedIncludePacks: z.boolean().optional(), - feedIncludeStreams: z.boolean().optional(), - showDecks: z.boolean().optional(), - feedIncludeDecks: z.boolean().optional(), -}).passthrough(); - // Zod schema for AppConfig validation const AppConfigSchema = z.object({ - theme: z.enum(['dark', 'light', 'black', 'pink']), + theme: ThemeSchema, relayMetadata: RelayMetadataSchema, useAppRelays: z.boolean(), feedSettings: FeedSettingsSchema, @@ -67,7 +39,7 @@ const AppConfigSchema = z.object({ faviconUrl: z.string(), linkPreviewUrl: z.string(), corsProxy: z.string(), - contentWarningPolicy: z.enum(['blur', 'hide', 'show']), + contentWarningPolicy: ContentWarningPolicySchema, }); export function AppProvider(props: AppProviderProps) { diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts index eb1f73a7..220cde89 100644 --- a/src/hooks/useEncryptedSettings.ts +++ b/src/hooks/useEncryptedSettings.ts @@ -6,6 +6,7 @@ import type { NostrFilter } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import type { Theme, FeedSettings, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ContentFilter } from './useContentFilters'; +import { EncryptedSettingsSchema } from '@/lib/schemas'; const SETTINGS_D_TAG = 'mew-metadata'; @@ -95,8 +96,14 @@ export function useEncryptedSettings() { try { const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content); - const parsed = JSON.parse(decrypted) as EncryptedSettings; - return parsed; + const json = JSON.parse(decrypted); + const result = EncryptedSettingsSchema.safeParse(json); + if (!result.success) { + console.warn('Encrypted settings failed validation, using partial data:', result.error.issues); + // Fall back to an empty object so invalid fields (e.g. theme as object) are dropped + return {} as EncryptedSettings; + } + return result.data as EncryptedSettings; } catch (error) { console.error('Failed to decrypt settings:', error); return null; diff --git a/src/hooks/useInitialSync.ts b/src/hooks/useInitialSync.ts index f5732e2e..86e3f286 100644 --- a/src/hooks/useInitialSync.ts +++ b/src/hooks/useInitialSync.ts @@ -5,6 +5,7 @@ import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import type { EncryptedSettings } from './useEncryptedSettings'; import { parseMuteTags, setCachedMuteItems } from './useMuteList'; +import { EncryptedSettingsSchema } from '@/lib/schemas'; export type SyncPhase = | 'idle' // No user logged in @@ -134,7 +135,12 @@ export function useInitialSync() { try { const decrypted = await user.signer.nip44.decrypt(user.pubkey, settingsEvent.content); - const parsed = JSON.parse(decrypted) as EncryptedSettings; + const json = JSON.parse(decrypted); + const result = EncryptedSettingsSchema.safeParse(json); + if (!result.success) { + console.warn('Encrypted settings failed validation during initial sync:', result.error.issues); + } + const parsed = (result.success ? result.data : {}) as EncryptedSettings; // Apply decrypted settings to local config (same logic as NostrSync) updateConfig((current) => { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 00000000..94dbf4f7 --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import type { Theme, ContentWarningPolicy } from '@/contexts/AppContext'; + +/** Zod schema for Theme validation */ +export const ThemeSchema = z.enum(['dark', 'light', 'black', 'pink']) satisfies z.ZodType; + +/** Zod schema for ContentWarningPolicy validation */ +export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']) satisfies z.ZodType; + +/** + * Zod schema for FeedSettings validation. + * All fields use .optional() so data with missing keys + * (from older encrypted settings) doesn't reject the whole object. + * Uses looseObject to preserve extra keys from newer encrypted settings. + * Missing fields get filled in by the defaultConfig merge downstream. + */ +export const FeedSettingsSchema = z.looseObject({ + feedIncludePosts: z.boolean().optional(), + feedIncludeReposts: z.boolean().optional(), + feedIncludeArticles: z.boolean().optional(), + showArticles: z.boolean().optional(), + showVines: z.boolean().optional(), + showPolls: z.boolean().optional(), + showTreasures: z.boolean().optional(), + showTreasureGeocaches: z.boolean().optional(), + showTreasureFoundLogs: z.boolean().optional(), + showColors: z.boolean().optional(), + showPacks: z.boolean().optional(), + showStreams: z.boolean().optional(), + feedIncludeVines: z.boolean().optional(), + feedIncludePolls: z.boolean().optional(), + feedIncludeTreasureGeocaches: z.boolean().optional(), + feedIncludeTreasureFoundLogs: z.boolean().optional(), + feedIncludeColors: z.boolean().optional(), + feedIncludePacks: z.boolean().optional(), + feedIncludeStreams: z.boolean().optional(), + showDecks: z.boolean().optional(), + feedIncludeDecks: z.boolean().optional(), +}); + +/** Zod schema for FilterRule validation */ +export const FilterRuleSchema = z.object({ + type: z.enum(['kind', 'content-regex', 'tag', 'author-metadata']), + field: z.string().optional(), + operator: z.enum(['equals', 'contains', 'regex', 'not-equals', 'not-contains']), + value: z.string(), + caseSensitive: z.boolean().optional(), +}); + +/** Zod schema for ContentFilter validation */ +export const ContentFilterSchema = z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + rules: z.array(FilterRuleSchema), + createdAt: z.number(), + updatedAt: z.number(), +}); + +/** + * Zod schema for EncryptedSettings validation. + * All fields are optional since settings are incrementally synced. + * Uses looseObject to preserve unknown keys from newer app versions. + */ +export const EncryptedSettingsSchema = z.looseObject({ + theme: ThemeSchema.optional(), + useAppRelays: z.boolean().optional(), + feedSettings: FeedSettingsSchema.optional(), + contentFilters: z.array(ContentFilterSchema).optional(), + contentWarningPolicy: ContentWarningPolicySchema.optional(), + notificationsCursor: z.number().optional(), + lastSync: z.number().optional(), +});