Skip to content

Commit 1307d02

Browse files
Address review: fail-closed cache freshness and wrong-shape document guards
- A NaN expiresAt passed both freshness comparisons and was served forever; the gate is now the positive check the doc already states. - read() served decodable-but-non-object documents verbatim; they now route through the same report + drop + miss path as parse failures. - _decodeListTools validated only the container, so a null element threw in the index builders before memoization, re-firing per call; the guard now requires object elements.
1 parent 32f8a66 commit 1307d02

2 files changed

Lines changed: 65 additions & 6 deletions

File tree

packages/client/src/client/responseCache.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -553,16 +553,21 @@ export class ClientResponseCache {
553553
* `entry.expiresAt > now()` (a missing `expiresAt` is never fresh),
554554
* checked BEFORE decoding so stale entries cost no parse. Every hit is
555555
* freshly parsed, so the caller owns the value outright. An entry whose
556-
* document does not parse (corrupted external store) is reported,
556+
* document does not parse or is not an object (corrupted external
557+
* store) is reported,
557558
* deleted, and treated as a miss — deleted because a fresh-but-corrupt
558559
* entry would otherwise re-parse and re-report on every read until its
559560
* `expiresAt` passes.
560561
*/
561562
async read(method: string, params?: string): Promise<{ value: unknown } | undefined> {
562563
const entry = await this._probe(method, params);
563-
if (entry?.expiresAt === undefined || entry.expiresAt <= this.now()) return undefined;
564+
if (entry?.expiresAt === undefined || !(entry.expiresAt > this.now())) return undefined;
564565
try {
565-
return { value: JSON.parse(entry.value) };
566+
const parsed: unknown = JSON.parse(entry.value);
567+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
568+
throw new TypeError('cached document is not an object');
569+
}
570+
return { value: parsed };
566571
} catch (error) {
567572
this._reportError(error);
568573
await this._deleteBoth(method, params ?? '');
@@ -657,15 +662,17 @@ export class ClientResponseCache {
657662
}
658663

659664
/** Parse a held `tools/list` document for the index builders; a document
660-
* that does not parse OR parses to something without a `tools` array
665+
* that does not parse OR whose `tools` is not an array of objects
661666
* (both mean a corrupted external store) is reported and treated as if
662667
* nothing were held. Callers memoize the outcome against the entry's
663668
* stamp, so a corrupt document costs one parse + report per stamp, not
664669
* per lookup. */
665670
private _decodeListTools(entry: CacheEntry): ListToolsResult | undefined {
666671
try {
667672
const parsed = JSON.parse(entry.value) as ListToolsResult | null;
668-
if (!Array.isArray(parsed?.tools)) throw new TypeError('cached tools/list document has no tools array');
673+
if (!Array.isArray(parsed?.tools) || !parsed.tools.every(t => t !== null && typeof t === 'object')) {
674+
throw new TypeError('cached tools/list document has a malformed tools array');
675+
}
669676
return parsed;
670677
} catch (error) {
671678
this._reportError(error);

packages/client/test/client/responseCacheCodec.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,59 @@ describe('response cache document codec', () => {
209209
// Memoized against the unchanged stamp: one report for the tool
210210
// index, one for the validator index — not one per lookup.
211211
expect(reported).toHaveLength(2);
212-
expect(String(reported[0])).toMatch(/no tools array/);
212+
expect(String(reported[0])).toMatch(/tools array/);
213213
}
214214
});
215+
216+
test('a NaN expiresAt is never fresh: the entry is not served at any clock value', async () => {
217+
const store: ResponseCacheStore = {
218+
get: () => ({ value: '{"tools":[]}', stamp: 1, expiresAt: NaN, scope: 'private' as const }),
219+
set: () => 1,
220+
delete: () => {},
221+
evict: () => {},
222+
clear: () => {}
223+
};
224+
const cache = new ClientResponseCache(store, true);
225+
expect(await cache.read('tools/list')).toBeUndefined();
226+
});
227+
228+
test('a fresh decodable-but-non-object document is reported, dropped, and read as a miss', async () => {
229+
for (const document of ['null', '"str"', '[]']) {
230+
const reported: unknown[] = [];
231+
const deletes: CacheKey[] = [];
232+
let entry: CacheEntry | undefined = { value: document, stamp: 1, expiresAt: Date.now() + 60_000, scope: 'private' };
233+
const store: ResponseCacheStore = {
234+
get: () => entry,
235+
set: () => 1,
236+
delete: key => {
237+
deletes.push(key);
238+
entry = undefined;
239+
},
240+
evict: () => {},
241+
clear: () => {}
242+
};
243+
const cache = new ClientResponseCache(store, true, error => reported.push(error));
244+
expect(await cache.read('tools/list')).toBeUndefined();
245+
expect(reported).toHaveLength(1);
246+
expect(String(reported[0])).toMatch(/not an object/);
247+
expect(deletes.length).toBeGreaterThan(0);
248+
}
249+
});
250+
251+
test('a tools/list document with non-object elements is reported once per stamp, not thrown per lookup', async () => {
252+
const reported: unknown[] = [];
253+
const store: ResponseCacheStore = {
254+
get: () => ({ value: '{"tools":[null]}', stamp: 9, scope: 'private' as const }),
255+
set: () => 1,
256+
delete: () => {},
257+
evict: () => {},
258+
clear: () => {}
259+
};
260+
const cache = new ClientResponseCache(store, true, error => reported.push(error));
261+
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
262+
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
263+
await expect(cache.outputValidator('a', () => undefined)).resolves.toBeUndefined();
264+
expect(reported).toHaveLength(2);
265+
expect(String(reported[0])).toMatch(/malformed tools array/);
266+
});
215267
});

0 commit comments

Comments
 (0)