From 3dab883e91e48b98152a9c62f87432880ed52283 Mon Sep 17 00:00:00 2001 From: JunghwanNA <70629228+shaun0927@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:18:34 +0900 Subject: [PATCH 1/2] Preserve Request semantics when proxying browser auth fetches The proxy auth wrapper handled (url, init) correctly but dropped method, headers, and body when callers supplied a real Request object. That made wrapper behavior depend on call shape and could break Request-based OAuth or proxy flows. This change reads method, headers, and body from Request inputs, lets an explicit init override them, and adds a focused regression test covering the Request path while preserving the existing URL+init behavior. Constraint: Keep the fix narrow and V1-compatible with no transport/proxy refactor Rejected: Broader fetch wrapper redesign | out of scope for a correctness bug fix Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve parity between Request input and (url, init) input in future proxyFetch changes Tested: Targeted Node 22 source-backed reproduction before and after fix; added regression test in client/src/lib/__tests__/proxyFetch.test.ts Not-tested: Full workspace Jest/CI run in this local environment --- client/src/lib/__tests__/proxyFetch.test.ts | 39 +++++++++++++++++++ client/src/lib/proxyFetch.ts | 42 +++++++++++++++------ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/client/src/lib/__tests__/proxyFetch.test.ts b/client/src/lib/__tests__/proxyFetch.test.ts index 5ac239eb9..22ef0bc87 100644 --- a/client/src/lib/__tests__/proxyFetch.test.ts +++ b/client/src/lib/__tests__/proxyFetch.test.ts @@ -241,4 +241,43 @@ describe("createProxyFetch", () => { const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); expect(callBody.url).toBe("https://example.com/from-request"); }); + + it("preserves Request method, headers, and body when input is a Request", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + body: "", + }), + }); + + const fetchFn = createProxyFetch(configWithProxy); + await fetchFn( + new Request("https://example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Test": "1", + }, + body: "grant_type=authorization_code&code=abc", + }), + ); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody).toEqual({ + url: "https://example.com/token", + init: { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-test": "1", + }, + body: "grant_type=authorization_code&code=abc", + }, + }); + }); }); diff --git a/client/src/lib/proxyFetch.ts b/client/src/lib/proxyFetch.ts index a0ff5977f..03c7a3a24 100644 --- a/client/src/lib/proxyFetch.ts +++ b/client/src/lib/proxyFetch.ts @@ -92,26 +92,46 @@ export function createProxyFetch(config: InspectorConfig): typeof fetch { input: RequestInfo | URL, init?: RequestInit, ): Promise => { + const requestInput = input instanceof Request ? input : undefined; const url = typeof input === "string" ? input - : input instanceof Request - ? input.url + : requestInput + ? requestInput.url : input.toString(); // Serialize body for JSON transport. URLSearchParams and similar don't // JSON-serialize (they become {}), so we must convert to string first. let serializedBody: string | undefined; - if (init?.body != null) { - if (typeof init.body === "string") { - serializedBody = init.body; - } else if (init.body instanceof URLSearchParams) { - serializedBody = init.body.toString(); + const requestBody = + requestInput && + !requestInput.bodyUsed && + !["GET", "HEAD"].includes(requestInput.method) + ? await requestInput.clone().text() + : undefined; + const effectiveBody = init?.body ?? requestBody; + if (effectiveBody != null) { + if (typeof effectiveBody === "string") { + serializedBody = effectiveBody; + } else if (effectiveBody instanceof URLSearchParams) { + serializedBody = effectiveBody.toString(); } else { - serializedBody = String(init.body); + serializedBody = String(effectiveBody); } } + const forwardedHeaders = new Headers(requestInput?.headers); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + forwardedHeaders.set(key, value); + }); + } + const serializedHeadersObject = Object.fromEntries(forwardedHeaders.entries()); + const serializedHeaders = + Object.keys(serializedHeadersObject).length > 0 + ? serializedHeadersObject + : undefined; + const proxyResponse = await fetch(`${proxyAddress}/fetch`, { method: "POST", headers: { @@ -121,10 +141,8 @@ export function createProxyFetch(config: InspectorConfig): typeof fetch { body: JSON.stringify({ url, init: { - method: init?.method, - headers: init?.headers - ? Object.fromEntries(new Headers(init.headers)) - : undefined, + method: init?.method ?? requestInput?.method, + headers: serializedHeaders, body: serializedBody, }, }), From 980dad41d520a5e9cf6e0037cdb23de7eaf72e40 Mon Sep 17 00:00:00 2001 From: JunghwanNA <70629228+shaun0927@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:49:20 +0900 Subject: [PATCH 2/2] Strengthen regression coverage for proxied Request overrides The original fix branch already repaired Request preservation in createProxyFetch, but the test suite only locked the basic Request passthrough case. This follow-up adds coverage for the override rule the implementation promises: explicit init values should still win over the Request's method, headers, and body. It also keeps the small formatting adjustment in proxyFetch.ts consistent with the surrounding style. Constraint: Keep follow-up scope limited to merge-readiness hardening for PR #1208 Rejected: Broader semantic changes around bodyUsed handling | no reviewer request and would expand scope beyond the validated bug Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve both Request passthrough and explicit init override semantics in future proxyFetch edits Tested: Targeted Node 22 harness validating override semantics against current source Not-tested: Full workspace Jest/CI run in this local environment --- client/src/lib/__tests__/proxyFetch.test.ts | 48 +++++++++++++++++++++ client/src/lib/proxyFetch.ts | 4 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/client/src/lib/__tests__/proxyFetch.test.ts b/client/src/lib/__tests__/proxyFetch.test.ts index 22ef0bc87..886c85de7 100644 --- a/client/src/lib/__tests__/proxyFetch.test.ts +++ b/client/src/lib/__tests__/proxyFetch.test.ts @@ -280,4 +280,52 @@ describe("createProxyFetch", () => { }, }); }); + + it("lets an explicit init override method, headers, and body from a Request input", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + body: "", + }), + }); + + const fetchFn = createProxyFetch(configWithProxy); + await fetchFn( + new Request("https://example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Test": "1", + }, + body: "grant_type=authorization_code&code=abc", + }), + { + method: "PUT", + headers: { + "X-Test": "override", + "X-Extra": "2", + }, + body: "override-body", + }, + ); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody).toEqual({ + url: "https://example.com/token", + init: { + method: "PUT", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-test": "override", + "x-extra": "2", + }, + body: "override-body", + }, + }); + }); }); diff --git a/client/src/lib/proxyFetch.ts b/client/src/lib/proxyFetch.ts index 03c7a3a24..c47b08e0b 100644 --- a/client/src/lib/proxyFetch.ts +++ b/client/src/lib/proxyFetch.ts @@ -126,7 +126,9 @@ export function createProxyFetch(config: InspectorConfig): typeof fetch { forwardedHeaders.set(key, value); }); } - const serializedHeadersObject = Object.fromEntries(forwardedHeaders.entries()); + const serializedHeadersObject = Object.fromEntries( + forwardedHeaders.entries(), + ); const serializedHeaders = Object.keys(serializedHeadersObject).length > 0 ? serializedHeadersObject