From 686463ad5c1e72c162b5544f98745187416546b4 Mon Sep 17 00:00:00 2001 From: oss69I-prep Date: Fri, 26 Jun 2026 16:05:06 +0900 Subject: [PATCH 1/2] Fix init hook nested json/context mutations leaking across requests --- source/core/Ky.ts | 35 ++++++++++++++++++++++++--- test/hooks.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index b8d83d8f..8af48caf 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -92,12 +92,41 @@ const cloneSearchParametersForInitHook = (searchParameters: SearchParamsOption | return cloneShallow(searchParameters) as SearchParamsOption | undefined; }; -// Shallow-clone mutable option properties so init hook mutations don't leak across requests. +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +// Deep-clone plain objects/arrays so init hooks can mutate NESTED `json`/`context` config +// without leaking state across requests. Non-plain values (Date, URLSearchParams, Headers, +// class instances, functions) are passed through `cloneShallow`, preserving their identity/type. +const cloneDeepForInitHook = (value: T): T => { + if (Array.isArray(value)) { + return value.map(item => cloneDeepForInitHook(item)) as T; + } + + if (isPlainObject(value)) { + const copy: Record = {}; + for (const key of Object.keys(value as Record)) { + copy[key] = cloneDeepForInitHook((value as Record)[key]); + } + + return copy as T; + } + + return cloneShallow(value); +}; + +// Clone mutable option properties so init hook mutations don't leak across requests. function cloneInitHookOptions(options: Options): Options { const clonedOptions: Options = { ...options, - json: cloneShallow(options.json), - context: cloneShallow(options.context)!, + json: cloneDeepForInitHook(options.json), + context: cloneDeepForInitHook(options.context)!, headers: cloneShallow(options.headers)!, searchParams: cloneSearchParametersForInitHook(options.searchParams), }; diff --git a/test/hooks.ts b/test/hooks.ts index 66e6f444..de4d095c 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -5059,6 +5059,34 @@ test('init hook in-place json mutations do not leak across requests', async t => t.deepEqual(seenRequestIdentifiers, ['1', '2']); }); +test('init hook nested json mutations do not leak across requests', async t => { + let requestIdentifier = 0; + const seenRequestIdentifiers: Array = []; + + const api = ky.extend({ + json: {meta: {}}, + hooks: { + init: [ + options => { + const json = options.json as {meta: {requestId?: string}}; + json.meta.requestId ??= String(++requestIdentifier); + }, + ], + }, + }); + + const fetch: typeof globalThis.fetch = async request => { + const body = await request.text(); + seenRequestIdentifiers.push(JSON.parse(body).meta.requestId); + return new Response('ok'); + }; + + await api.post('https://example.com', {fetch}); + await api.post('https://example.com', {fetch}); + + t.deepEqual(seenRequestIdentifiers, ['1', '2']); +}); + test('init hooks preserve non-plain json values', async t => { const createdAt = new Date('2024-01-01T00:00:00.000Z'); @@ -5208,6 +5236,38 @@ test('init hook in-place context mutations do not leak across requests', async t t.deepEqual(seenRequestIdentifiers, [1, 2]); }); +test('init hook nested context mutations do not leak across requests', async t => { + const seenRequestIdentifiers: Array = []; + let requestIdentifier = 0; + + const api = ky.extend({ + context: {trace: {}}, + hooks: { + init: [ + options => { + const context = options.context as {trace: {requestIdentifier?: number}}; + context.trace.requestIdentifier ??= ++requestIdentifier; + }, + ], + beforeRequest: [ + ({options}) => { + seenRequestIdentifiers.push((options.context.trace as {requestIdentifier?: number}).requestIdentifier); + }, + ], + }, + }); + + await api.get('https://example.com', { + fetch: async () => new Response('ok'), + }); + + await api.get('https://example.com', { + fetch: async () => new Response('ok'), + }); + + t.deepEqual(seenRequestIdentifiers, [1, 2]); +}); + test('multiple init hooks see each other\'s mutations on the shared cloned options', async t => { const api = ky.extend({ json: {a: 1}, From 1da35ba2a08038025b4245a83d812b368bbcad97 Mon Sep 17 00:00:00 2001 From: greymoth Date: Fri, 26 Jun 2026 22:08:58 +0900 Subject: [PATCH 2/2] Keep json shallow-cloned in init hooks; deep-clone only context Deep-cloning json overflowed the stack for cyclic or very deep serializer input before stringifyJson ran. Revert json to a shallow clone (restoring main's behavior) and keep context deep-cloned, since that is what the cross-request leak fix was actually about. Drop the nested-json test and add a regression test for cyclic json with a custom stringifyJson and an init hook present. --- source/core/Ky.ts | 10 +++++++-- test/hooks.ts | 55 +++++++++++++++++++++++------------------------ 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 8af48caf..4ca4784a 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -101,9 +101,11 @@ const isPlainObject = (value: unknown): value is Record => { return prototype === Object.prototype || prototype === null; }; -// Deep-clone plain objects/arrays so init hooks can mutate NESTED `json`/`context` config +// Deep-clone plain objects/arrays so init hooks can mutate NESTED `context` metadata // without leaking state across requests. Non-plain values (Date, URLSearchParams, Headers, // class instances, functions) are passed through `cloneShallow`, preserving their identity/type. +// Only used for `context`: `json` is intentionally cloned shallowly (see `cloneInitHookOptions`) +// because it is arbitrary serializer input and may be cyclic or huge. const cloneDeepForInitHook = (value: T): T => { if (Array.isArray(value)) { return value.map(item => cloneDeepForInitHook(item)) as T; @@ -122,10 +124,14 @@ const cloneDeepForInitHook = (value: T): T => { }; // Clone mutable option properties so init hook mutations don't leak across requests. +// `json` is cloned shallowly: it is typed as `unknown` and `stringifyJson` is the documented +// escape hatch for custom serialization, so Ky must not recursively walk it (deep-cloning +// cyclic or huge serializer input throws `RangeError: Maximum call stack size exceeded`). +// `context` is request metadata Ky owns, so it is safe to deep-clone for nested-mutation isolation. function cloneInitHookOptions(options: Options): Options { const clonedOptions: Options = { ...options, - json: cloneDeepForInitHook(options.json), + json: cloneShallow(options.json), context: cloneDeepForInitHook(options.context)!, headers: cloneShallow(options.headers)!, searchParams: cloneSearchParametersForInitHook(options.searchParams), diff --git a/test/hooks.ts b/test/hooks.ts index de4d095c..67d54f29 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -5059,34 +5059,6 @@ test('init hook in-place json mutations do not leak across requests', async t => t.deepEqual(seenRequestIdentifiers, ['1', '2']); }); -test('init hook nested json mutations do not leak across requests', async t => { - let requestIdentifier = 0; - const seenRequestIdentifiers: Array = []; - - const api = ky.extend({ - json: {meta: {}}, - hooks: { - init: [ - options => { - const json = options.json as {meta: {requestId?: string}}; - json.meta.requestId ??= String(++requestIdentifier); - }, - ], - }, - }); - - const fetch: typeof globalThis.fetch = async request => { - const body = await request.text(); - seenRequestIdentifiers.push(JSON.parse(body).meta.requestId); - return new Response('ok'); - }; - - await api.post('https://example.com', {fetch}); - await api.post('https://example.com', {fetch}); - - t.deepEqual(seenRequestIdentifiers, ['1', '2']); -}); - test('init hooks preserve non-plain json values', async t => { const createdAt = new Date('2024-01-01T00:00:00.000Z'); @@ -5124,6 +5096,33 @@ test('custom stringifyJson receives cyclic json unchanged when init hooks are ab t.deepEqual(response, {ok: true}); }); +test('custom stringifyJson receives cyclic json without crashing when init hooks are present', async t => { + const json: {self?: unknown} = {}; + json.self = json; + + let stringifyCalls = 0; + + const response = await ky.post('https://example.com', { + fetch: async request => new Response(await request.text()), + json, + stringifyJson(data) { + stringifyCalls++; + t.is((data as {self?: unknown}).self !== undefined, true); + return '{"ok":true}'; + }, + hooks: { + init: [ + options => { + options.headers = {'x-init': 'present'}; + }, + ], + }, + }).json<{ok: boolean}>(); + + t.is(stringifyCalls, 1); + t.deepEqual(response, {ok: true}); +}); + test('custom stringifyJson supports function-valued json when init hooks are present', async t => { const response = await ky.post('https://example.com', { fetch: async request => new Response(await request.text()),