Files
CertScope-Public/lib/inspect/http.test.ts
T

99 lines
5.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { followRedirectChain, requestOnce, validateProbeUrl } from "./http";
const response = (url: string, status: number, location: string | null = null) => ({ url, status, location, headers: {} });
const publicResolution = (addresses: string[]) => ({ addresses, families: { ipv4: { addresses, status: "ok" as const, error: null }, ipv6: { addresses: [], status: "absent" as const, error: null } }, _meta: { status: "ok" as const, partial: false, errors: [] } });
describe("HTTP target policy", () => {
it.each(["ftp://example.com/", "https://user:pass@example.com/", "https://example.com:8443/", "https://127.0.0.1/"])('rejects unsafe target %s', (value) => {
expect(() => validateProbeUrl(new URL(value))).toThrow();
});
it("accepts standard public-host HTTP(S) targets", () => {
expect(() => validateProbeUrl(new URL("https://example.com/path?q=1"))).not.toThrow();
});
});
describe("redirect chain", () => {
it("follows bounded valid redirects with an injected transport", async () => {
const requester = async (url: URL) => url.pathname === "/"
? response(url.toString(), 302, "/final")
: response(url.toString(), 200);
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain).toHaveLength(2);
expect(chain[1].status).toBe(200);
});
it("records relative and protocol-relative redirects", async () => {
const requester = async (url: URL) => url.pathname === "/" ? response(url.toString(), 301, "//example.com/final") : response(url.toString(), 204);
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain.at(-1)?.status).toBe(204);
});
it("blocks HTTPS to HTTP downgrades before requesting the target", async () => {
const requested: string[] = [];
const requester = async (url: URL) => { requested.push(url.toString()); return response(url.toString(), 302, "http://example.com/"); };
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain.at(-1)).toMatchObject({ code: "HTTPS_DOWNGRADE" });
expect(requested).toEqual(["https://example.com/"]);
});
it("detects self-loops and multi-hop normalized loops", async () => {
const self = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 301, url.toString()));
expect(self.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
const cycle = await followRedirectChain(new URL("https://example.com/a"), async (url) => url.pathname === "/a" ? response(url.toString(), 302, "/b") : response(url.toString(), 302, "/a"));
expect(cycle.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
const equivalent = await followRedirectChain(new URL("https://EXAMPLE.com./"), async (url) => response(url.toString(), 302, "https://example.com/"));
expect(equivalent.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
});
it("records unsafe redirects and redirect budget exhaustion", async () => {
const unsafe = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "http://127.0.0.1/admin"));
expect(unsafe.at(-1)).toMatchObject({ error: expect.any(String), code: "REDIRECT_TARGET_BLOCKED" });
const limited = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "/again"), 1);
expect(limited.at(-1)).toMatchObject({ code: "REDIRECT_LIMIT" });
});
it("redacts redirect userinfo from error evidence", async () => {
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "https://user:secret@example.com/private"));
expect(chain.at(-1)).toMatchObject({ code: "REDIRECT_TARGET_BLOCKED" });
expect(JSON.stringify(chain)).not.toContain("secret");
});
it("enforces the total deadline across slow hops", async () => {
const started = Date.now();
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => {
await new Promise((resolve) => setTimeout(resolve, 50));
return response(url.toString(), 200);
}, 3, Date.now() + 10);
expect(chain.at(-1)?.error).toBe("HTTP request timed out.");
expect(Date.now() - started).toBeLessThan(100);
});
});
describe("multi-address HTTP strategy", () => {
it("tries the next validated public address after a transport failure", async () => {
const attempts: string[] = [];
const result = await requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
resolve: async () => publicResolution(["1.1.1.1", "2.2.2.2"]),
request: async (url, address) => {
attempts.push(address);
if (address === "1.1.1.1") throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" });
return response(url.toString(), 200);
},
});
expect(result).toMatchObject({ status: 200, address: "2.2.2.2" });
expect(attempts).toEqual(["1.1.1.1", "2.2.2.2"]);
expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "HTTP request failed (ECONNREFUSED)." }]);
});
it("rejects an unsafe address returned by a resolver before any request", async () => {
const attempted: string[] = [];
await expect(requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
resolve: async () => publicResolution(["1.1.1.1", "127.0.0.1"]),
request: async (_url, address) => { attempted.push(address); return response("https://example.com/", 200); },
})).rejects.toThrow(/non-public address space/);
expect(attempted).toEqual([]);
});
});