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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user