From 435cd781d713b915616612d280e5ae6b9bd29bfb Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 20 Oct 2026 16:19:26 +0700 Subject: [PATCH 1/4] fix: harden the wallet store against tampered and polluted state --- src/storage/secureStorage.ts | 6 +++--- src/store/walletStore.ts | 2 +- src/utils/schemas.ts | 9 ++++++++- src/utils/storeHelpers.ts | 13 +++++++++++++ tests/storage/secureStorage.test.ts | 12 +----------- tests/store/walletStore.test.ts | 16 +++++++++++++++- tests/utils/schemas.test.ts | 11 +++++++++++ tests/utils/storeHelpers.test.ts | 26 ++++++++++++++++++++++++++ 8 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/storage/secureStorage.ts b/src/storage/secureStorage.ts index d21d3bb..3ff5f0a 100644 --- a/src/storage/secureStorage.ts +++ b/src/storage/secureStorage.ts @@ -18,8 +18,8 @@ * On-device only - this module never syncs or backs up anything anywhere. Thin wrapper * around react-native-keychain: * - namespaces entries per wallet identifier - * - marks the encrypted seed/entropy device-only, excluded from ever migrating to a new - * device via an encrypted backup restore (iOS-specific; a no-op on Android) + * - marks the encryption key and the encrypted seed/entropy device-only, excluded from ever + * migrating to a new device via an encrypted backup restore (iOS-specific; a no-op on Android) * - guards against a hung keychain call with a timeout * - all values are still encrypted at rest by the OS keychain/keystore * @@ -325,7 +325,7 @@ function throwIfFailed(failedServices: string[]): void { const secureStorage: SecureStorage = { async setEncryptionKey(key, identifier) { - return setSecureValue(ENCRYPTION_KEY, key, identifier, false) + return setSecureValue(ENCRYPTION_KEY, key, identifier, true) }, async getEncryptionKey(identifier) { diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 6b50f71..2d1de14 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -164,7 +164,6 @@ export function createWalletStore(): WalletStoreInstance { name: 'wallet-storage', storage: createJSONStorage(() => defaultStorageAdapter), partialize: (state: WalletState) => ({ - addresses: state.addresses, balances: state.balances, balanceLoading: {}, lastBalanceUpdate: state.lastBalanceUpdate, @@ -176,6 +175,7 @@ export function createWalletStore(): WalletStoreInstance { return (state: WalletState | undefined) => { if (state) { log('🔄 Rehydrating wallet state - resetting loading states') + state.addresses = {} state.walletLoading = {} state.balanceLoading = {} // Reset runtime-only state diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts index da0ebb0..9561da3 100644 --- a/src/utils/schemas.ts +++ b/src/utils/schemas.ts @@ -89,12 +89,19 @@ export const assetConfigSchema = z.object({ */ export const accountIndexSchema = z.number().int().nonnegative() +/** + * Property names that would reach Object.prototype when used as a key of a plain object + */ +export const RESERVED_OBJECT_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) + /** * Network name schema */ export const networkNameSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, { message: 'Network name must contain only alphanumeric characters, hyphens, and underscores', -}).min(1) +}).min(1).refine((name) => !RESERVED_OBJECT_KEYS.has(name), { + message: 'Network name must not be a reserved object property name', +}) /** * Balance string schema (valid number string) diff --git a/src/utils/storeHelpers.ts b/src/utils/storeHelpers.ts index 1c6ce1f..e1cd04a 100644 --- a/src/utils/storeHelpers.ts +++ b/src/utils/storeHelpers.ts @@ -25,6 +25,13 @@ import type { HRPC } from '../types' import type { WalletState } from '../store/walletStore' import { produce } from 'immer' import { logWarn } from './logger' +import { RESERVED_OBJECT_KEYS } from './schemas' + +function assertStateKey(name: string, value: string): void { + if (RESERVED_OBJECT_KEYS.has(value)) { + throw new Error(`${name} must not be a reserved object property name`) + } +} /** * Require that worklet is initialized and return HRPC instance @@ -83,6 +90,9 @@ export function updateBalanceInState( tokenKey: string, balance: string, ) { + assertStateKey('walletId', walletId) + assertStateKey('network', network) + return produce(prev, (state) => { state.balances[walletId] ??= {} state.balances[walletId][network] ??= {} @@ -112,6 +122,9 @@ export function updateAddressInState( accountIndex: number, address: string, ) { + assertStateKey('walletId', walletId) + assertStateKey('network', network) + return produce(prev, (state) => { state.addresses[walletId] ??= {} state.addresses[walletId][network] ??= {} diff --git a/tests/storage/secureStorage.test.ts b/tests/storage/secureStorage.test.ts index 33b9b1a..c1f17ce 100644 --- a/tests/storage/secureStorage.test.ts +++ b/tests/storage/secureStorage.test.ts @@ -221,17 +221,7 @@ describe('secureStorage', () => { }) describe('accessible policy', () => { - it('stores the encryption key without a device-only restriction (WHEN_UNLOCKED)', async () => { - await storage.setEncryptionKey('key-value', TEST_ID) - - expect(Keychain.setGenericPassword).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED }) - ) - }) - - it.each(['setEncryptedSeed', 'setEncryptedEntropy'] as const)( + it.each(['setEncryptionKey', 'setEncryptedSeed', 'setEncryptedEntropy'] as const)( 'stores %s as device-only (WHEN_UNLOCKED_THIS_DEVICE_ONLY)', async (method) => { await storage[method]('value', TEST_ID) diff --git a/tests/store/walletStore.test.ts b/tests/store/walletStore.test.ts index 41ddd4b..2df390b 100644 --- a/tests/store/walletStore.test.ts +++ b/tests/store/walletStore.test.ts @@ -285,13 +285,27 @@ describe('walletStore', () => { expect(stateBeforeRehydration.currentOperation).toEqual(null) expect(stateBeforeRehydration.tempWalletId).toEqual('temp_w') - expect(stateBeforeRehydration.addresses).toEqual(mockState.addresses) + expect(stateBeforeRehydration.addresses).toEqual({}) expect(stateBeforeRehydration.balances).toEqual(mockState.balances) expect(stateBeforeRehydration.lastBalanceUpdate).toEqual(mockState.lastBalanceUpdate) expect(stateBeforeRehydration.activeWalletId).toBeNull() }) + it('should not persist addresses', () => { + const storeInstance = createWalletStore() + const persistOptions = (storeInstance as any).persist.getOptions() + + const persisted = persistOptions.partialize({ + ...storeInstance.getState(), + addresses: { w1: { ethereum: { 0: '0xabc' } } }, + balances: { w1: { ethereum: { 0: { asset: '100' } } } }, + }) + + expect(persisted.addresses).toBeUndefined() + expect(persisted.balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } }) + }) + it('should do nothing if state is undefined during rehydration', () => { const storeInstance = createWalletStore() const persistOptions = (storeInstance as any).persist.getOptions() diff --git a/tests/utils/schemas.test.ts b/tests/utils/schemas.test.ts index 61dcf0c..5bdea89 100644 --- a/tests/utils/schemas.test.ts +++ b/tests/utils/schemas.test.ts @@ -20,9 +20,20 @@ import { workletResponseSchema, balanceResponseSchema, accountMethodResponseSchema, + networkNameSchema, } from '../../src/utils/schemas' describe('schemas', () => { + describe('networkNameSchema', () => { + it.each(['ethereum', 'arbitrum-one', 'bsc_testnet'])('should accept %s', (name) => { + expect(networkNameSchema.parse(name)).toBe(name) + }) + + it.each(['__proto__', 'constructor', 'prototype'])('should reject the reserved key %s', (name) => { + expect(() => networkNameSchema.parse(name)).toThrow('Network name must not be a reserved object property name') + }) + }) + describe('workletResponseSchema', () => { it('should validate valid worklet response', () => { const valid = { diff --git a/tests/utils/storeHelpers.test.ts b/tests/utils/storeHelpers.test.ts index c4c7401..066f6ca 100644 --- a/tests/utils/storeHelpers.test.ts +++ b/tests/utils/storeHelpers.test.ts @@ -186,6 +186,32 @@ describe('storeHelpers', () => { }) }) + describe('reserved object keys', () => { + it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as walletId', (key) => { + const prev = { addresses: {}, balances: {} } as any + + expect(() => updateAddressInState(prev, key, 'ethereum', 0, '0xabc')).toThrow( + 'walletId must not be a reserved object property name' + ) + expect(() => updateBalanceInState(prev, key, 'ethereum', 0, 'asset', '1')).toThrow( + 'walletId must not be a reserved object property name' + ) + expect(({} as any).ethereum).toBeUndefined() + }) + + it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as network', (key) => { + const prev = { addresses: {}, balances: {} } as any + + expect(() => updateAddressInState(prev, 'wallet-1', key, 0, '0xabc')).toThrow( + 'network must not be a reserved object property name' + ) + expect(() => updateBalanceInState(prev, 'wallet-1', key, 0, 'asset', '1')).toThrow( + 'network must not be a reserved object property name' + ) + expect(({} as any)[0]).toBeUndefined() + }) + }) + describe('updateAddressInState', () => { it('should update address in empty state', () => { const prev = { From 9d2b01f2aa59cc73a8000f0f999f54e4ecb50ef6 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 20 Oct 2026 16:37:58 +0700 Subject: [PATCH 2/4] fix: reject reserved wallet ids at every entry point --- docs/security.md | 4 ++- src/storage/secureStorage.ts | 17 +++++----- src/store/walletStore.ts | 5 +-- src/utils/storeHelpers.ts | 19 ++++++----- tests/storage/secureStorage.test.ts | 8 +++++ tests/store/walletStore.test.ts | 50 ++++++++++++++++++++-------- tests/utils/storeHelpers.test.ts | 51 +++++++++++++++++++++-------- 7 files changed, 107 insertions(+), 47 deletions(-) diff --git a/docs/security.md b/docs/security.md index 149dcdf..1b70bc6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,7 +42,9 @@ fresh; none of it is retained in memory between calls. ### secureStorage is local-only `secureStorage` never syncs or backs up anything - every value it writes -stays on the device that wrote it. Cross-device recovery, if an app wants +stays on the device that wrote it. Wallets created before the encryption key +became device-only keep the previous accessibility class until the key is +written again. Cross-device recovery, if an app wants it, is a separate, explicit concern layered on top of this library's public API, not something this library performs itself. diff --git a/src/storage/secureStorage.ts b/src/storage/secureStorage.ts index 3ff5f0a..4b1c228 100644 --- a/src/storage/secureStorage.ts +++ b/src/storage/secureStorage.ts @@ -31,6 +31,7 @@ import * as Keychain from 'react-native-keychain' import * as Crypto from 'expo-crypto' import { log, logError } from '../utils/logger' +import { RESERVED_OBJECT_KEYS } from '../utils/schemas' export class SecureStorageError extends Error { constructor( @@ -115,6 +116,9 @@ function validateIdentifier(identifier: string): void { 'Identifier contains invalid characters. Allowed: alphanumeric, dots, dashes, underscores, plus signs, and email format' ) } + if (RESERVED_OBJECT_KEYS.has(trimmed)) { + throw new ValidationError('Identifier must not be a reserved object property name') + } } function validateValue(value: string, fieldName: string): void { @@ -228,17 +232,14 @@ export interface SecureStorage { async function setSecureValue( baseKey: BaseKey, value: string, - identifier: string, - deviceOnly: boolean + identifier: string ): Promise { validateValue(value, 'value') validateIdentifier(identifier) try { const storageKey = await deriveStorageKey(baseKey, identifier) - const accessible = deviceOnly - ? Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY - : Keychain.ACCESSIBLE.WHEN_UNLOCKED + const accessible = Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY const result = await withTimeout( Keychain.setGenericPassword(baseKey, value, { service: storageKey, accessible }), @@ -325,7 +326,7 @@ function throwIfFailed(failedServices: string[]): void { const secureStorage: SecureStorage = { async setEncryptionKey(key, identifier) { - return setSecureValue(ENCRYPTION_KEY, key, identifier, true) + return setSecureValue(ENCRYPTION_KEY, key, identifier) }, async getEncryptionKey(identifier) { @@ -333,7 +334,7 @@ const secureStorage: SecureStorage = { }, async setEncryptedSeed(encryptedSeed, identifier) { - return setSecureValue(ENCRYPTED_SEED, encryptedSeed, identifier, true) + return setSecureValue(ENCRYPTED_SEED, encryptedSeed, identifier) }, async getEncryptedSeed(identifier) { @@ -341,7 +342,7 @@ const secureStorage: SecureStorage = { }, async setEncryptedEntropy(encryptedEntropy, identifier) { - return setSecureValue(ENCRYPTED_ENTROPY, encryptedEntropy, identifier, true) + return setSecureValue(ENCRYPTED_ENTROPY, encryptedEntropy, identifier) }, async getEncryptedEntropy(identifier) { diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 2d1de14..5b035a3 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -49,7 +49,7 @@ * * ## Important Notes * - * - Addresses: Stored in Zustand (derived/computed state, deterministic, no refetching needed) + * - Addresses: Stored in Zustand for the session only (derived from the worklet, never persisted) * - Balances: Stored in Zustand (single source of truth), use TanStack Query via useBalance() hook for fetching * - Zustand is the single source of truth for balances * - TanStack Query reads from Zustand (initialData) and updates Zustand after fetch @@ -164,6 +164,7 @@ export function createWalletStore(): WalletStoreInstance { name: 'wallet-storage', storage: createJSONStorage(() => defaultStorageAdapter), partialize: (state: WalletState) => ({ + // addresses are never persisted: they are re-derived from the worklet each session balances: state.balances, balanceLoading: {}, lastBalanceUpdate: state.lastBalanceUpdate, @@ -175,7 +176,7 @@ export function createWalletStore(): WalletStoreInstance { return (state: WalletState | undefined) => { if (state) { log('🔄 Rehydrating wallet state - resetting loading states') - state.addresses = {} + state.addresses = {} // drop addresses persisted by older versions state.walletLoading = {} state.balanceLoading = {} // Reset runtime-only state diff --git a/src/utils/storeHelpers.ts b/src/utils/storeHelpers.ts index e1cd04a..06f5696 100644 --- a/src/utils/storeHelpers.ts +++ b/src/utils/storeHelpers.ts @@ -27,6 +27,11 @@ import { produce } from 'immer' import { logWarn } from './logger' import { RESERVED_OBJECT_KEYS } from './schemas' +/** + * Reject a key that would reach Object.prototype when used on a plain object + * + * @throws {Error} If the value is `__proto__`, `constructor` or `prototype` + */ function assertStateKey(name: string, value: string): void { if (RESERVED_OBJECT_KEYS.has(value)) { throw new Error(`${name} must not be a reserved object property name`) @@ -81,6 +86,7 @@ export async function requireInitialized(): Promise { * @param tokenKey - Token key (address or 'native') * @param balance - Balance value * @returns The updated state + * @throws {Error} If walletId or network is a reserved object property name */ export function updateBalanceInState( prev: WalletState, @@ -114,6 +120,7 @@ export function updateBalanceInState( * @param accountIndex - Account index * @param address - Address value * @returns The updated state + * @throws {Error} If walletId or network is a reserved object property name */ export function updateAddressInState( prev: WalletState, @@ -141,6 +148,7 @@ export function updateAddressInState( * * @param walletId - Optional wallet identifier parameter * @returns Resolved wallet identifier (never null) + * @throws {Error} If the resolved identifier is a reserved object property name * * @example * ```typescript @@ -149,14 +157,9 @@ export function updateAddressInState( * ``` */ export function resolveWalletId(walletId?: string): string { - if (walletId) { - return walletId - } - - const walletStore = getWalletStore() - const activeWalletId = walletStore.getState().activeWalletId - - return activeWalletId || '__temporary__' + const resolved = walletId || getWalletStore().getState().activeWalletId || '__temporary__' + assertStateKey('walletId', resolved) + return resolved } /** diff --git a/tests/storage/secureStorage.test.ts b/tests/storage/secureStorage.test.ts index c1f17ce..914eeef 100644 --- a/tests/storage/secureStorage.test.ts +++ b/tests/storage/secureStorage.test.ts @@ -220,6 +220,14 @@ describe('secureStorage', () => { }) }) + describe('identifier validation', () => { + it.each(['__proto__', 'constructor', 'prototype'])('should reject the reserved identifier %s', async (identifier) => { + await expect(storage.setEncryptionKey('key-value', identifier)).rejects.toThrow( + 'Identifier must not be a reserved object property name' + ) + }) + }) + describe('accessible policy', () => { it.each(['setEncryptionKey', 'setEncryptedSeed', 'setEncryptedEntropy'] as const)( 'stores %s as device-only (WHEN_UNLOCKED_THIS_DEVICE_ONLY)', diff --git a/tests/store/walletStore.test.ts b/tests/store/walletStore.test.ts index 2df390b..7d1a6f4 100644 --- a/tests/store/walletStore.test.ts +++ b/tests/store/walletStore.test.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { createJSONStorage } from 'zustand/middleware' import { createWalletStore, getWalletStore, @@ -253,6 +254,41 @@ describe('walletStore', () => { }) }) + describe('persist', () => { + it('should not persist addresses', () => { + const storeInstance = createWalletStore() + const persistOptions = (storeInstance as any).persist.getOptions() + + const persisted = persistOptions.partialize({ + ...storeInstance.getState(), + addresses: { w1: { ethereum: { 0: '0xabc' } } }, + balances: { w1: { ethereum: { 0: { asset: '100' } } } }, + }) + + expect(persisted.addresses).toBeUndefined() + expect(persisted.balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } }) + }) + + it('should drop addresses from a persisted blob written by an older version', async () => { + const storeInstance = createWalletStore() + const legacyBlob = JSON.stringify({ + state: { + addresses: { w1: { ethereum: { 0: '0xATTACKER' } } }, + balances: { w1: { ethereum: { 0: { asset: '100' } } } }, + }, + version: 0, + }) + const legacyStorage = { getItem: async () => legacyBlob, setItem: async () => {}, removeItem: async () => {} } + ;(storeInstance as any).persist.setOptions({ storage: createJSONStorage(() => legacyStorage) }) + + await (storeInstance as any).persist.rehydrate() + + expect(storeInstance.getState().addresses).toEqual({}) + expect(storeInstance.getState().balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } }) + storeInstance.setState(mockInitState) + }) + }) + describe('onRehydrateStorage logic', () => { it('should reset runtime-only states during rehydration', () => { const mockState: WalletState = { @@ -292,20 +328,6 @@ describe('walletStore', () => { expect(stateBeforeRehydration.activeWalletId).toBeNull() }) - it('should not persist addresses', () => { - const storeInstance = createWalletStore() - const persistOptions = (storeInstance as any).persist.getOptions() - - const persisted = persistOptions.partialize({ - ...storeInstance.getState(), - addresses: { w1: { ethereum: { 0: '0xabc' } } }, - balances: { w1: { ethereum: { 0: { asset: '100' } } } }, - }) - - expect(persisted.addresses).toBeUndefined() - expect(persisted.balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } }) - }) - it('should do nothing if state is undefined during rehydration', () => { const storeInstance = createWalletStore() const persistOptions = (storeInstance as any).persist.getOptions() diff --git a/tests/utils/storeHelpers.test.ts b/tests/utils/storeHelpers.test.ts index 066f6ca..24c2715 100644 --- a/tests/utils/storeHelpers.test.ts +++ b/tests/utils/storeHelpers.test.ts @@ -16,6 +16,7 @@ import { requireInitialized, updateBalanceInState, updateAddressInState, + resolveWalletId, } from '../../src/utils/storeHelpers' import { getWorkletStore } from '../../src/store/workletStore' @@ -184,31 +185,20 @@ describe('storeHelpers', () => { expect(result.balances?.['wallet-1']?.ethereum?.[0]?.native).toBe('50') expect(result.balances?.['wallet-1']?.ethereum?.[0]?.['0x123']).toBe('1000') }) - }) - - describe('reserved object keys', () => { it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as walletId', (key) => { - const prev = { addresses: {}, balances: {} } as any + const prev = { balances: {} } as any - expect(() => updateAddressInState(prev, key, 'ethereum', 0, '0xabc')).toThrow( - 'walletId must not be a reserved object property name' - ) expect(() => updateBalanceInState(prev, key, 'ethereum', 0, 'asset', '1')).toThrow( 'walletId must not be a reserved object property name' ) - expect(({} as any).ethereum).toBeUndefined() }) it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as network', (key) => { - const prev = { addresses: {}, balances: {} } as any + const prev = { balances: {} } as any - expect(() => updateAddressInState(prev, 'wallet-1', key, 0, '0xabc')).toThrow( - 'network must not be a reserved object property name' - ) expect(() => updateBalanceInState(prev, 'wallet-1', key, 0, 'asset', '1')).toThrow( 'network must not be a reserved object property name' ) - expect(({} as any)[0]).toBeUndefined() }) }) @@ -312,5 +302,38 @@ describe('storeHelpers', () => { '0x842d35Cc6634C0532925a3b844Bc9e7595f0bEb0' ) }) + + it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as walletId', (key) => { + const prev = { addresses: {} } as any + + expect(() => updateAddressInState(prev, key, 'ethereum', 0, '0xabc')).toThrow( + 'walletId must not be a reserved object property name' + ) + }) + + it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as network', (key) => { + const prev = { addresses: {} } as any + + expect(() => updateAddressInState(prev, 'wallet-1', key, 0, '0xabc')).toThrow( + 'network must not be a reserved object property name' + ) + }) + + it('should leave Object.prototype untouched when __proto__ is rejected', () => { + const prev = { addresses: {} } as any + + expect(() => updateAddressInState(prev, '__proto__', 'ethereum', 0, '0xabc')).toThrow() + + expect(({} as any).ethereum).toBeUndefined() + }) }) -}) + + describe('resolveWalletId', () => { + it('should return the given wallet id', () => { + expect(resolveWalletId('wallet-1')).toBe('wallet-1') + }) + + it.each(['__proto__', 'constructor', 'prototype'])('should reject %s', (key) => { + expect(() => resolveWalletId(key)).toThrow('walletId must not be a reserved object property name') + }) + })}) From 9321991bf6c7b78df9362d631389c1cfa0843a92 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Tue, 20 Oct 2026 17:08:29 +0700 Subject: [PATCH 3/4] remove redundant comments --- src/store/walletStore.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 5b035a3..d8501af 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -164,7 +164,6 @@ export function createWalletStore(): WalletStoreInstance { name: 'wallet-storage', storage: createJSONStorage(() => defaultStorageAdapter), partialize: (state: WalletState) => ({ - // addresses are never persisted: they are re-derived from the worklet each session balances: state.balances, balanceLoading: {}, lastBalanceUpdate: state.lastBalanceUpdate, @@ -176,7 +175,7 @@ export function createWalletStore(): WalletStoreInstance { return (state: WalletState | undefined) => { if (state) { log('🔄 Rehydrating wallet state - resetting loading states') - state.addresses = {} // drop addresses persisted by older versions + state.addresses = {} state.walletLoading = {} state.balanceLoading = {} // Reset runtime-only state From 126ce8ccbda2b8b8c60fc2c4e6e552d2dbff1ac6 Mon Sep 17 00:00:00 2001 From: Quoc Le Date: Thu, 22 Oct 2026 16:16:36 +0700 Subject: [PATCH 4/4] fix: block every reserved object key, not just three --- src/storage/secureStorage.ts | 4 ++-- src/utils/schemas.ts | 8 +++----- src/utils/storeHelpers.ts | 9 ++------- tests/storage/secureStorage.test.ts | 2 +- tests/utils/schemas.test.ts | 4 ++-- tests/utils/storeHelpers.test.ts | 10 +++++----- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/storage/secureStorage.ts b/src/storage/secureStorage.ts index 4b1c228..7a6a15d 100644 --- a/src/storage/secureStorage.ts +++ b/src/storage/secureStorage.ts @@ -31,7 +31,7 @@ import * as Keychain from 'react-native-keychain' import * as Crypto from 'expo-crypto' import { log, logError } from '../utils/logger' -import { RESERVED_OBJECT_KEYS } from '../utils/schemas' +import { isReservedObjectKey } from '../utils/schemas' export class SecureStorageError extends Error { constructor( @@ -116,7 +116,7 @@ function validateIdentifier(identifier: string): void { 'Identifier contains invalid characters. Allowed: alphanumeric, dots, dashes, underscores, plus signs, and email format' ) } - if (RESERVED_OBJECT_KEYS.has(trimmed)) { + if (isReservedObjectKey(trimmed)) { throw new ValidationError('Identifier must not be a reserved object property name') } } diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts index 9561da3..3d34ac8 100644 --- a/src/utils/schemas.ts +++ b/src/utils/schemas.ts @@ -89,17 +89,15 @@ export const assetConfigSchema = z.object({ */ export const accountIndexSchema = z.number().int().nonnegative() -/** - * Property names that would reach Object.prototype when used as a key of a plain object - */ -export const RESERVED_OBJECT_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) +export const isReservedObjectKey = (key: string): boolean => + key in Object.prototype || key === 'prototype' /** * Network name schema */ export const networkNameSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, { message: 'Network name must contain only alphanumeric characters, hyphens, and underscores', -}).min(1).refine((name) => !RESERVED_OBJECT_KEYS.has(name), { +}).min(1).refine((name) => !isReservedObjectKey(name), { message: 'Network name must not be a reserved object property name', }) diff --git a/src/utils/storeHelpers.ts b/src/utils/storeHelpers.ts index 06f5696..0a9c389 100644 --- a/src/utils/storeHelpers.ts +++ b/src/utils/storeHelpers.ts @@ -25,15 +25,10 @@ import type { HRPC } from '../types' import type { WalletState } from '../store/walletStore' import { produce } from 'immer' import { logWarn } from './logger' -import { RESERVED_OBJECT_KEYS } from './schemas' +import { isReservedObjectKey } from './schemas' -/** - * Reject a key that would reach Object.prototype when used on a plain object - * - * @throws {Error} If the value is `__proto__`, `constructor` or `prototype` - */ function assertStateKey(name: string, value: string): void { - if (RESERVED_OBJECT_KEYS.has(value)) { + if (isReservedObjectKey(value)) { throw new Error(`${name} must not be a reserved object property name`) } } diff --git a/tests/storage/secureStorage.test.ts b/tests/storage/secureStorage.test.ts index 914eeef..6627c3a 100644 --- a/tests/storage/secureStorage.test.ts +++ b/tests/storage/secureStorage.test.ts @@ -221,7 +221,7 @@ describe('secureStorage', () => { }) describe('identifier validation', () => { - it.each(['__proto__', 'constructor', 'prototype'])('should reject the reserved identifier %s', async (identifier) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject the reserved identifier %s', async (identifier) => { await expect(storage.setEncryptionKey('key-value', identifier)).rejects.toThrow( 'Identifier must not be a reserved object property name' ) diff --git a/tests/utils/schemas.test.ts b/tests/utils/schemas.test.ts index 5bdea89..4e1404b 100644 --- a/tests/utils/schemas.test.ts +++ b/tests/utils/schemas.test.ts @@ -25,11 +25,11 @@ import { describe('schemas', () => { describe('networkNameSchema', () => { - it.each(['ethereum', 'arbitrum-one', 'bsc_testnet'])('should accept %s', (name) => { + it.each(['ethereum', 'arbitrum-one', 'bsc_testnet', 'toStringify', 'constructorX'])('should accept %s', (name) => { expect(networkNameSchema.parse(name)).toBe(name) }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject the reserved key %s', (name) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject the reserved key %s', (name) => { expect(() => networkNameSchema.parse(name)).toThrow('Network name must not be a reserved object property name') }) }) diff --git a/tests/utils/storeHelpers.test.ts b/tests/utils/storeHelpers.test.ts index 24c2715..1e20e4b 100644 --- a/tests/utils/storeHelpers.test.ts +++ b/tests/utils/storeHelpers.test.ts @@ -185,7 +185,7 @@ describe('storeHelpers', () => { expect(result.balances?.['wallet-1']?.ethereum?.[0]?.native).toBe('50') expect(result.balances?.['wallet-1']?.ethereum?.[0]?.['0x123']).toBe('1000') }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as walletId', (key) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as walletId', (key) => { const prev = { balances: {} } as any expect(() => updateBalanceInState(prev, key, 'ethereum', 0, 'asset', '1')).toThrow( @@ -193,7 +193,7 @@ describe('storeHelpers', () => { ) }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as network', (key) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as network', (key) => { const prev = { balances: {} } as any expect(() => updateBalanceInState(prev, 'wallet-1', key, 0, 'asset', '1')).toThrow( @@ -303,7 +303,7 @@ describe('storeHelpers', () => { ) }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as walletId', (key) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as walletId', (key) => { const prev = { addresses: {} } as any expect(() => updateAddressInState(prev, key, 'ethereum', 0, '0xabc')).toThrow( @@ -311,7 +311,7 @@ describe('storeHelpers', () => { ) }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject %s as network', (key) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as network', (key) => { const prev = { addresses: {} } as any expect(() => updateAddressInState(prev, 'wallet-1', key, 0, '0xabc')).toThrow( @@ -333,7 +333,7 @@ describe('storeHelpers', () => { expect(resolveWalletId('wallet-1')).toBe('wallet-1') }) - it.each(['__proto__', 'constructor', 'prototype'])('should reject %s', (key) => { + it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s', (key) => { expect(() => resolveWalletId(key)).toThrow('walletId must not be a reserved object property name') }) })})