Skip to content

Commit 8be136b

Browse files
VGontier-cmdVivien GontierDogPawHat
authored
fix(query-persist-client-core): discard malformed persisted queries (#11919)
* fix(query-persist-client-core): handle nullish persisted query state * fix(query-persist-client-core): validate deserialized queries * fix(query-persist-client-core): validate persisted query fields * fix(query-persist-client-core): narrow persisted entry validation --------- Co-authored-by: Vivien Gontier <vivien.gontier@paygreen.fr> Co-authored-by: Ciarán Curley <ciaran.curley.11.11@gmail.com>
1 parent d88ba6e commit 8be136b

5 files changed

Lines changed: 213 additions & 33 deletions

File tree

‎.changeset/proud-donuts-shave.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/query-persist-client-core": patch
3+
---
4+
5+
Validate persisted query keys, hashes, state objects, and data update timestamps before retrieval, restoration, garbage collection, or filtered removal. Remove malformed entries without interrupting subsequent queries, while preserving valid `null` data.

‎docs/framework/react/plugins/createPersister.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ useMutation({
104104
This function would attempt to retrieve persisted query by `queryHash`.
105105
If `query` is `expired`, `busted` or `malformed` it would be removed from the storage instead, and `undefined` would be returned.
106106

107+
Persisted entries must have a string `queryHash`, an array `queryKey`, and a `state` object with a finite numeric `dataUpdatedAt`. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. `null` remains valid query data.
108+
107109
### `persisterGc(): Promise<void>`
108110

109111
This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries.

‎docs/framework/vue/plugins/createPersister.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ useMutation({
101101
This function would attempt to retrieve persisted query by `queryHash`.
102102
If `query` is `expired`, `busted` or `malformed` it would be removed from the storage instead, and `undefined` would be returned.
103103

104+
Persisted entries must have a string `queryHash`, an array `queryKey`, and a `state` object with a finite numeric `dataUpdatedAt`. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. `null` remains valid query data.
105+
104106
### `persisterGc(): Promise<void>`
105107

106108
This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries.

‎packages/query-persist-client-core/src/__tests__/createPersister.test.ts‎

Lines changed: 167 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ function setupPersister(
7171
}
7272
}
7373

74+
function createPersistedQuery(state: Partial<QueryState> = {}): PersistedQuery {
75+
const { query, queryKey, queryHash } = setupPersister(['foo'], {
76+
storage: undefined,
77+
})
78+
79+
return {
80+
buster: '',
81+
queryKey,
82+
queryHash,
83+
state: {
84+
...query.state,
85+
data: 'cached',
86+
dataUpdatedAt: Date.now(),
87+
...state,
88+
},
89+
}
90+
}
91+
92+
function getMalformedEntries() {
93+
const persistedQuery = createPersistedQuery()
94+
const values: Array<unknown> = [
95+
null,
96+
{},
97+
42,
98+
{ ...persistedQuery, state: null },
99+
{ ...persistedQuery, state: 42 },
100+
{ ...persistedQuery, queryHash: undefined },
101+
{ ...persistedQuery, queryKey: 'foo' },
102+
{
103+
...persistedQuery,
104+
state: { ...persistedQuery.state, dataUpdatedAt: {} },
105+
},
106+
]
107+
108+
return values.map((value) => JSON.stringify(value))
109+
}
110+
74111
describe('createPersister', () => {
75112
beforeAll(() => {
76113
vi.useFakeTimers()
@@ -171,10 +208,7 @@ describe('createPersister', () => {
171208

172209
await storage.setItem(
173210
storageKey,
174-
JSON.stringify({
175-
buster: '',
176-
state: { dataUpdatedAt: undefined },
177-
}),
211+
JSON.stringify(createPersistedQuery({ dataUpdatedAt: undefined })),
178212
)
179213

180214
await persister.persisterFn(queryFn, context, query)
@@ -195,10 +229,7 @@ describe('createPersister', () => {
195229

196230
await storage.setItem(
197231
storageKey,
198-
JSON.stringify({
199-
buster: '',
200-
state: { dataUpdatedAt: Date.now() - 200 },
201-
}),
232+
JSON.stringify(createPersistedQuery({ dataUpdatedAt: Date.now() - 200 })),
202233
)
203234

204235
await persister.persisterFn(queryFn, context, query)
@@ -219,8 +250,8 @@ describe('createPersister', () => {
219250
await storage.setItem(
220251
storageKey,
221252
JSON.stringify({
253+
...createPersistedQuery(),
222254
buster: 'bust',
223-
state: { dataUpdatedAt: Date.now() },
224255
}),
225256
)
226257

@@ -243,10 +274,7 @@ describe('createPersister', () => {
243274

244275
await storage.setItem(
245276
storageKey,
246-
JSON.stringify({
247-
buster: '',
248-
state: { dataUpdatedAt, data: '' },
249-
}),
277+
JSON.stringify(createPersistedQuery({ dataUpdatedAt, data: '' })),
250278
)
251279

252280
await persister.persisterFn(queryFn, context, query)
@@ -271,10 +299,7 @@ describe('createPersister', () => {
271299

272300
await storage.setItem(
273301
storageKey,
274-
JSON.stringify({
275-
buster: '',
276-
state: { dataUpdatedAt: Date.now(), data: '' },
277-
}),
302+
JSON.stringify(createPersistedQuery({ data: '' })),
278303
)
279304

280305
await persister.persisterFn(queryFn, context, query)
@@ -298,10 +323,9 @@ describe('createPersister', () => {
298323

299324
await storage.setItem(
300325
storageKey,
301-
JSON.stringify({
302-
buster: '',
303-
state: { dataUpdatedAt: Date.now() + 1000, data: '' },
304-
}),
326+
JSON.stringify(
327+
createPersistedQuery({ dataUpdatedAt: Date.now() + 1000, data: '' }),
328+
),
305329
)
306330

307331
await persister.persisterFn(queryFn, context, query)
@@ -325,10 +349,7 @@ describe('createPersister', () => {
325349

326350
await storage.setItem(
327351
storageKey,
328-
JSON.stringify({
329-
buster: '',
330-
state: { dataUpdatedAt: Date.now(), data: '' },
331-
}),
352+
JSON.stringify(createPersistedQuery({ data: '' })),
332353
)
333354

334355
await persister.persisterFn(queryFn, context, query)
@@ -387,10 +408,7 @@ describe('createPersister', () => {
387408

388409
await storage.setItem(
389410
storageKey,
390-
JSON.stringify({
391-
buster: '',
392-
state: { dataUpdatedAt },
393-
}),
411+
JSON.stringify(createPersistedQuery({ dataUpdatedAt })),
394412
)
395413

396414
await persister.persisterFn(queryFn, context, query)
@@ -414,10 +432,7 @@ describe('createPersister', () => {
414432

415433
await storage.setItem(
416434
storageKey,
417-
JSON.stringify({
418-
buster: '',
419-
state: { dataUpdatedAt: Date.now(), data: '' },
420-
}),
435+
JSON.stringify(createPersistedQuery({ data: '' })),
421436
)
422437

423438
await persister.persisterFn(queryFn, context, query)
@@ -605,6 +620,42 @@ describe('createPersister', () => {
605620
})
606621

607622
describe('retrieveQuery', () => {
623+
it.each(getMalformedEntries())(
624+
'should remove malformed entry %s without restoring it',
625+
async (value) => {
626+
const storage = getFreshStorage()
627+
const { persister, queryHash, storageKey } = setupPersister(['foo'], {
628+
storage,
629+
})
630+
631+
await storage.setItem(storageKey, value)
632+
633+
const restoredData = await persister.retrieveQuery(queryHash)
634+
expect(restoredData).toBeUndefined()
635+
await expect(storage.getItem(storageKey)).resolves.toBeUndefined()
636+
},
637+
)
638+
639+
it.each([NaN, Infinity, -Infinity])(
640+
'should remove an entry with dataUpdatedAt %s from a custom deserializer without scheduling restoration',
641+
async (value) => {
642+
const storage = getFreshStorage()
643+
const { persister, queryHash, storageKey } = setupPersister(['foo'], {
644+
storage,
645+
deserialize: () => createPersistedQuery({ dataUpdatedAt: value }),
646+
})
647+
const afterRestore = vi.fn<(persistedQuery: PersistedQuery) => void>()
648+
await storage.setItem(storageKey, 'stored')
649+
650+
await expect(
651+
persister.retrieveQuery(queryHash, afterRestore),
652+
).resolves.toBeUndefined()
653+
await expect(storage.getItem(storageKey)).resolves.toBeUndefined()
654+
await vi.advanceTimersByTimeAsync(0)
655+
expect(afterRestore).not.toHaveBeenCalled()
656+
},
657+
)
658+
608659
it('should return the persisted data when called without a restore callback', async () => {
609660
const storage = getFreshStorage()
610661
const { persister, client, queryHash, queryKey } = setupPersister(
@@ -668,6 +719,33 @@ describe('createPersister', () => {
668719
await persister.persisterGc()
669720
await expect(storage.entries()).resolves.toHaveLength(1)
670721
})
722+
723+
it.each(getMalformedEntries())(
724+
'should remove malformed entry %s and continue garbage collection',
725+
async (value) => {
726+
const storage = getFreshStorage()
727+
const { persister, client, queryKey, storageKey } = setupPersister(
728+
['foo'],
729+
{ storage },
730+
)
731+
const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]`
732+
const expiredKey = `${PERSISTER_KEY_PREFIX}-["expired"]`
733+
734+
await storage.setItem(malformedKey, value)
735+
client.setQueryData(['expired'], 'expired', { updatedAt: 1 })
736+
await persister.persistQueryByKey(['expired'], client)
737+
client.setQueryData(queryKey, null)
738+
await persister.persistQueryByKey(queryKey, client)
739+
const persistedData = await storage.getItem(storageKey)
740+
741+
await persister.persisterGc()
742+
await expect(storage.getItem(malformedKey)).resolves.toBeUndefined()
743+
await expect(storage.getItem(expiredKey)).resolves.toBeUndefined()
744+
await expect(storage.entries()).resolves.toStrictEqual([
745+
[storageKey, persistedData],
746+
])
747+
},
748+
)
671749
})
672750

673751
describe('restoreQueries', () => {
@@ -805,9 +883,65 @@ describe('createPersister', () => {
805883
await expect(storage.entries()).resolves.toHaveLength(0)
806884
expect(client.getQueryCache().getAll()).toHaveLength(0)
807885
})
886+
887+
it.each(getMalformedEntries())(
888+
'should remove malformed entry %s and restore the next query',
889+
async (value) => {
890+
const storage = getFreshStorage()
891+
const { persister, client, queryKey, storageKey } = setupPersister(
892+
['foo'],
893+
{ storage },
894+
)
895+
const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]`
896+
897+
await storage.setItem(malformedKey, value)
898+
client.setQueryData(queryKey, null)
899+
await persister.persistQueryByKey(queryKey, client)
900+
const persistedData = await storage.getItem(storageKey)
901+
client.clear()
902+
903+
await persister.restoreQueries(client)
904+
await expect(storage.getItem(malformedKey)).resolves.toBeUndefined()
905+
await expect(storage.entries()).resolves.toStrictEqual([
906+
[storageKey, persistedData],
907+
])
908+
expect(client.getQueryCache().getAll()).toHaveLength(1)
909+
expect(client.getQueryData(queryKey)).toBeNull()
910+
},
911+
)
808912
})
809913

810914
describe('removeQueries', () => {
915+
describe.each([false, true])('with exact: %s', (exact) => {
916+
it.each(getMalformedEntries())(
917+
'should remove malformed entry %s and continue removing matching queries',
918+
async (value) => {
919+
const storage = getFreshStorage()
920+
const { persister, client, queryKey, storageKey } = setupPersister(
921+
['foo'],
922+
{ storage },
923+
)
924+
const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]`
925+
const unrelatedKey = `${PERSISTER_KEY_PREFIX}-["bar"]`
926+
927+
await storage.setItem(malformedKey, value)
928+
client.setQueryData(queryKey, null)
929+
await persister.persistQueryByKey(queryKey, client)
930+
client.setQueryData(['bar'], null)
931+
await persister.persistQueryByKey(['bar'], client)
932+
const unrelatedData = await storage.getItem(unrelatedKey)
933+
934+
await persister.removeQueries({ queryKey, exact })
935+
936+
await expect(storage.getItem(malformedKey)).resolves.toBeUndefined()
937+
await expect(storage.getItem(storageKey)).resolves.toBeUndefined()
938+
await expect(storage.entries()).resolves.toStrictEqual([
939+
[unrelatedKey, unrelatedData],
940+
])
941+
},
942+
)
943+
})
944+
811945
it('should remove restore queries from storage without filters', async () => {
812946
const storage = getFreshStorage()
813947
const { persister, client, queryKey } = setupPersister(['foo'], {

‎packages/query-persist-client-core/src/createPersister.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,31 @@ export function experimental_createQueryPersister<TStorageValue = string>({
148148
refetchOnRestore = true,
149149
filters: persistFilters,
150150
}: StoragePersisterOptions<TStorageValue>) {
151+
/**
152+
* Checks the fields used to filter, expire, and restore a deserialized query.
153+
* @param value - The deserialized value to check.
154+
* @returns `true` if the value can be processed as a persisted query.
155+
*/
156+
function isValidPersistedQuery(value: unknown) {
157+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
158+
return false
159+
}
160+
161+
const persistedQuery = value as Partial<Omit<PersistedQuery, 'state'>> & {
162+
state?: Partial<QueryState> | null
163+
}
164+
const state = persistedQuery.state
165+
166+
return (
167+
typeof persistedQuery.queryHash === 'string' &&
168+
Array.isArray(persistedQuery.queryKey) &&
169+
typeof state === 'object' &&
170+
state !== null &&
171+
!Array.isArray(state) &&
172+
Number.isFinite(state.dataUpdatedAt)
173+
)
174+
}
175+
151176
/**
152177
* Checks whether a persisted query should be discarded.
153178
* @param persistedQuery - The persisted query to check.
@@ -190,6 +215,9 @@ export function experimental_createQueryPersister<TStorageValue = string>({
190215
let persistedQuery: PersistedQuery
191216
try {
192217
persistedQuery = await deserialize(storedData)
218+
if (!isValidPersistedQuery(persistedQuery)) {
219+
throw new Error('Invalid persisted query')
220+
}
193221
} catch {
194222
await storage.removeItem(storageKey)
195223
return
@@ -336,6 +364,9 @@ export function experimental_createQueryPersister<TStorageValue = string>({
336364
let persistedQuery: PersistedQuery
337365
try {
338366
persistedQuery = await deserialize(value)
367+
if (!isValidPersistedQuery(persistedQuery)) {
368+
throw new Error('Invalid persisted query')
369+
}
339370
} catch {
340371
await storage.removeItem(key)
341372
continue
@@ -374,6 +405,9 @@ export function experimental_createQueryPersister<TStorageValue = string>({
374405
let persistedQuery: PersistedQuery
375406
try {
376407
persistedQuery = await deserialize(value)
408+
if (!isValidPersistedQuery(persistedQuery)) {
409+
throw new Error('Invalid persisted query')
410+
}
377411
} catch {
378412
await storage.removeItem(key)
379413
continue
@@ -434,6 +468,9 @@ export function experimental_createQueryPersister<TStorageValue = string>({
434468
let persistedQuery: PersistedQuery
435469
try {
436470
persistedQuery = await deserialize(value)
471+
if (!isValidPersistedQuery(persistedQuery)) {
472+
throw new Error('Invalid persisted query')
473+
}
437474
} catch {
438475
await storage.removeItem(key)
439476
continue

0 commit comments

Comments
 (0)