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.
This commit is contained in:
Alex Gleason
2026-02-23 11:37:17 -06:00
parent f826379d95
commit 0d48221476
4 changed files with 93 additions and 34 deletions
+3 -31
View File
@@ -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<RelayMetadata>;
// 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) {
+9 -2
View File
@@ -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;
+7 -1
View File
@@ -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) => {
+74
View File
@@ -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<Theme>;
/** Zod schema for ContentWarningPolicy validation */
export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']) satisfies z.ZodType<ContentWarningPolicy>;
/**
* 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(),
});