test: harden guarded fetch redirect coverage

This commit is contained in:
Peter Steinberger 2026-03-13 18:21:02 +00:00
parent f3d4bb4103
commit 5aa79f1ba4
1 changed files with 81 additions and 14 deletions

View File

@ -13,6 +13,34 @@ function okResponse(body = "ok"): Response {
return new Response(body, { status: 200 }); return new Response(body, { status: 200 });
} }
function getSecondRequestHeaders(fetchImpl: ReturnType<typeof vi.fn>): Headers {
const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit];
return new Headers(secondInit.headers);
}
async function expectRedirectFailure(params: {
url: string;
responses: Response[];
expectedError: RegExp;
lookupFn?: NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>;
maxRedirects?: number;
}) {
const fetchImpl = vi.fn();
for (const response of params.responses) {
fetchImpl.mockResolvedValueOnce(response);
}
await expect(
fetchWithSsrFGuard({
url: params.url,
fetchImpl,
...(params.lookupFn ? { lookupFn: params.lookupFn } : {}),
...(params.maxRedirects === undefined ? {} : { maxRedirects: params.maxRedirects }),
}),
).rejects.toThrow(params.expectedError);
return fetchImpl;
}
describe("fetchWithSsrFGuard hardening", () => { describe("fetchWithSsrFGuard hardening", () => {
type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>; type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>;
const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [ const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [
@ -33,11 +61,6 @@ describe("fetchWithSsrFGuard hardening", () => {
const createPublicLookup = (): LookupFn => const createPublicLookup = (): LookupFn =>
vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn;
const getSecondRequestHeaders = (fetchImpl: ReturnType<typeof vi.fn>): Headers => {
const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit];
return new Headers(secondInit.headers);
};
async function runProxyModeDispatcherTest(params: { async function runProxyModeDispatcherTest(params: {
mode: (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE]; mode: (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE];
expectEnvProxy: boolean; expectEnvProxy: boolean;
@ -112,15 +135,12 @@ describe("fetchWithSsrFGuard hardening", () => {
it("blocks redirect chains that hop to private hosts", async () => { it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = createPublicLookup(); const lookupFn = createPublicLookup();
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/")); const fetchImpl = await expectRedirectFailure({
url: "https://public.example/start",
await expect( responses: [redirectResponse("http://127.0.0.1:6379/")],
fetchWithSsrFGuard({ expectedError: /private|internal|blocked/i,
url: "https://public.example/start", lookupFn,
fetchImpl, });
lookupFn,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).toHaveBeenCalledTimes(1); expect(fetchImpl).toHaveBeenCalledTimes(1);
}); });
@ -136,6 +156,18 @@ describe("fetchWithSsrFGuard hardening", () => {
expect(fetchImpl).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled();
}); });
it("does not let wildcard allowlists match the apex host", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "https://assets.example.com/pic.png",
fetchImpl,
policy: { hostnameAllowlist: ["*.assets.example.com"] },
}),
).rejects.toThrow(/allowlist/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("allows wildcard allowlisted hosts", async () => { it("allows wildcard allowlisted hosts", async () => {
const lookupFn = createPublicLookup(); const lookupFn = createPublicLookup();
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 })); const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
@ -211,6 +243,41 @@ describe("fetchWithSsrFGuard hardening", () => {
await result.release(); await result.release();
}); });
it.each([
{
name: "rejects redirects without a location header",
responses: [new Response(null, { status: 302 })],
expectedError: /missing location header/i,
maxRedirects: undefined,
},
{
name: "rejects redirect loops",
responses: [
redirectResponse("https://public.example/next"),
redirectResponse("https://public.example/next"),
],
expectedError: /redirect loop/i,
maxRedirects: undefined,
},
{
name: "rejects too many redirects",
responses: [
redirectResponse("https://public.example/one"),
redirectResponse("https://public.example/two"),
],
expectedError: /too many redirects/i,
maxRedirects: 1,
},
])("$name", async ({ responses, expectedError, maxRedirects }) => {
await expectRedirectFailure({
url: "https://public.example/start",
responses,
expectedError,
lookupFn: createPublicLookup(),
maxRedirects,
});
});
it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => {
await runProxyModeDispatcherTest({ await runProxyModeDispatcherTest({
mode: GUARDED_FETCH_MODE.STRICT, mode: GUARDED_FETCH_MODE.STRICT,