diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 9971f07b981..92c26c06c6d 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -8,6 +8,10 @@ function redirectResponse(location: string): Response { }); } +function okResponse(body = "ok"): Response { + return new Response(body, { status: 200 }); +} + describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; @@ -88,4 +92,60 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); await result.release(); }); + + it("strips sensitive headers when redirect crosses origins", async () => { + const lookupFn = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(redirectResponse("https://cdn.example.com/asset")) + .mockResolvedValueOnce(okResponse()); + + const result = await fetchWithSsrFGuard({ + url: "https://api.example.com/start", + fetchImpl, + lookupFn, + init: { + headers: { + Authorization: "Bearer secret", + Cookie: "session=abc", + "X-Trace": "1", + }, + }, + }); + + const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + const headers = new Headers(secondInit.headers); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("x-trace")).toBe("1"); + await result.release(); + }); + + it("keeps headers when redirect stays on same origin", async () => { + const lookupFn = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(redirectResponse("/next")) + .mockResolvedValueOnce(okResponse()); + + const result = await fetchWithSsrFGuard({ + url: "https://api.example.com/start", + fetchImpl, + lookupFn, + init: { + headers: { + Authorization: "Bearer secret", + }, + }, + }); + + const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + const headers = new Headers(secondInit.headers); + expect(headers.get("authorization")).toBe("Bearer secret"); + await result.release(); + }); }); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index b75f468b348..c3e2b7864b1 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -32,11 +32,28 @@ export type GuardedFetchResult = { }; const DEFAULT_MAX_REDIRECTS = 3; +const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ + "authorization", + "proxy-authorization", + "cookie", + "cookie2", +]; function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } +function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { + if (!init?.headers) { + return init; + } + const headers = new Headers(init.headers); + for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) { + headers.delete(header); + } + return { ...init, headers }; +} + function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { signal?: AbortSignal; cleanup: () => void; @@ -99,6 +116,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise(); let currentUrl = params.url; + let currentInit = params.init ? { ...params.init } : undefined; let redirectCount = 0; while (true) { @@ -125,7 +143,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise