diff --git a/packages/engine/src/services/pageNetworkPolicy.browser.test.ts b/packages/engine/src/services/pageNetworkPolicy.browser.test.ts index c996e6e8b6..020dc9882b 100644 --- a/packages/engine/src/services/pageNetworkPolicy.browser.test.ts +++ b/packages/engine/src/services/pageNetworkPolicy.browser.test.ts @@ -15,7 +15,11 @@ const answers: Record = { "meta.hf-test": ["169.254.169.254"], "public.hf-test": ["8.8.8.8"], }; +let flakyLookups = 0; const resolveHost = async (host: string) => { + // Fails its first lookup only, like a passing DNS blip. + if (host === "flaky.hf-test" && flakyLookups++ === 0) throw new Error(`EAI_AGAIN ${host}`); + if (host === "flaky.hf-test") return ["8.8.4.4"]; const found = answers[host]; if (!found) throw new Error(`ENOTFOUND ${host}`); return found; @@ -64,7 +68,7 @@ describe.skipIf(!hasBrowser)("restrictPageNetwork in Chrome", () => { // The render browser runs frames in-process (no site isolation); mirror that. browser = await launchTestChrome(chromePath, [ "--disable-features=IsolateOrigins,site-per-process", - "--host-resolver-rules=MAP private.hf-test 127.0.0.1, MAP public.hf-test 127.0.0.1, MAP meta.hf-test 169.254.169.254", + "--host-resolver-rules=MAP private.hf-test 127.0.0.1, MAP public.hf-test 127.0.0.1, MAP flaky.hf-test 127.0.0.1, MAP meta.hf-test 169.254.169.254", ]); }); afterEach(async () => { @@ -174,6 +178,13 @@ describe.skipIf(!hasBrowser)("restrictPageNetwork in Chrome", () => { expect(otherHits).toEqual(["/public"]); }); + it("asks again after a lookup fails, rather than refusing the host for the rest of the page", async () => { + const restricted = await openRestricted([]); + expect(await fetchText(restricted, `http://flaky.hf-test:${otherPort}/first`)).toBe("refused"); + expect(await fetchText(restricted, `http://flaky.hf-test:${otherPort}/second`)).toBe("secret"); + expect(otherHits).toEqual(["/second"]); + }); + it("refuses a name it cannot resolve", async () => { const refused: string[] = []; const restricted = await openRestricted(refused); diff --git a/packages/engine/src/services/pageNetworkPolicy.ts b/packages/engine/src/services/pageNetworkPolicy.ts index 687030f4cf..63cf4617c9 100644 --- a/packages/engine/src/services/pageNetworkPolicy.ts +++ b/packages/engine/src/services/pageNetworkPolicy.ts @@ -8,16 +8,20 @@ type ResolveHost = (hostname: string) => Promise; const systemResolveHost: ResolveHost = async (hostname) => (await lookup(hostname, { all: true, verbatim: true })).map(({ address }) => address); -async function isPublicHost(hostname: string, resolveHost: ResolveHost): Promise { +type HostVerdict = "public" | "non-public" | "unresolved"; + +async function hostVerdict(hostname: string, resolveHost: ResolveHost): Promise { const host = hostname.replace(/^\[|\]$/g, ""); - if (isBlockedNetworkHost(host)) return false; - if (isIP(host)) return true; + if (isBlockedNetworkHost(host)) return "non-public"; + if (isIP(host)) return "public"; + let addresses: string[]; try { - const addresses = await resolveHost(host); - return addresses.length > 0 && !addresses.some((a) => isBlockedNetworkHost(a.split("%")[0]!)); + addresses = await resolveHost(host); } catch { - return false; + return "unresolved"; } + if (addresses.length === 0) return "unresolved"; + return addresses.some((a) => isBlockedNetworkHost(a.split("%")[0]!)) ? "non-public" : "public"; } /** @@ -30,7 +34,10 @@ export async function restrictPageNetwork( resolveHost: ResolveHost = systemResolveHost, ): Promise { const own = new URL(allowedOrigin).origin; - const verdicts = new Map>(); + const verdicts = new Map>(); + const forgetFailedLookup = (host: string, asked: Promise, answer: HostVerdict) => { + if (answer === "unresolved" && verdicts.get(host) === asked) verdicts.delete(host); + }; const allows = (url: string): Promise => { let target: URL; try { @@ -41,12 +48,15 @@ export async function restrictPageNetwork( if (target.origin === own || !/^(https?|wss?):$/.test(target.protocol)) { return Promise.resolve(true); } - let verdict = verdicts.get(target.hostname); + const host = target.hostname; + let verdict = verdicts.get(host); if (!verdict) { - verdict = isPublicHost(target.hostname, resolveHost); - verdicts.set(target.hostname, verdict); + const asked = hostVerdict(host, resolveHost); + verdicts.set(host, asked); + void asked.then((answer) => forgetFailedLookup(host, asked, answer)); + verdict = asked; } - return verdict; + return verdict.then((answer) => answer === "public"); }; const client: CDPSession = await page.createCDPSession(); client.on("Fetch.requestPaused", (event: Protocol.Fetch.RequestPausedEvent) => {