import { afterEach, describe, expect, it, vi } from "vitest"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { rmSync } from "node:fs"; import { Agent, interceptors } from "undici"; import { UrlNotAllowedError, assertDownloadableUrl, createGuardedConnector, isBlockedAddress, } from "../src/lib/netGuard.js"; import { loadModules } from "./helpers.js"; // Resolve "internal.test" para um IP privado e o resto para um público, // sem depender de DNS real. vi.mock("node:dns", () => ({ lookup: ( host: string, opts: { all?: boolean }, cb: (err: Error | null, address: unknown, family?: number) => void, ) => { const address = host === "internal.test" ? "10.0.0.5" : "93.184.216.34"; if (opts?.all) cb(null, [{ address, family: 4 }]); else cb(null, address, 4); }, })); afterEach(() => { delete process.env.ALLOW_PRIVATE_NETWORK; }); describe("isBlockedAddress", () => { it.each([ "0.0.0.0", "10.1.2.3", "100.64.0.1", "127.0.0.1", "169.254.169.254", "172.16.0.1", "172.31.255.255", "192.168.1.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12:3456::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:7f00:1", "::ffff:10.0.0.1", "64:ff9b::7f00:1", ])("bloqueia %s", (ip) => { expect(isBlockedAddress(ip)).toBe(true); }); it.each([ "8.8.8.8", "1.1.1.1", "93.184.216.34", "172.15.0.1", "172.32.0.1", "100.63.0.1", "198.20.0.1", "2606:4700:4700::1111", "::ffff:8.8.8.8", ])("permite %s", (ip) => { expect(isBlockedAddress(ip)).toBe(false); }); it("bloqueia o que não é IP", () => { expect(isBlockedAddress("example.com")).toBe(true); }); }); describe("assertDownloadableUrl", () => { it("aceita http e https públicos", () => { expect(assertDownloadableUrl("https://example.com/a.zip").hostname).toBe("example.com"); expect(assertDownloadableUrl("http://8.8.8.8/a.zip").hostname).toBe("8.8.8.8"); }); it.each(["ftp://example.com/a", "file:///etc/passwd", "javascript:alert(1)"])( "recusa o protocolo de %s", (url) => { expect(() => assertDownloadableUrl(url)).toThrow(/http and https/); }, ); it("recusa URL inválida", () => { expect(() => assertDownloadableUrl("not a url")).toThrow(UrlNotAllowedError); }); it.each([ "http://localhost:3000/", "http://app.localhost/", "http://127.0.0.1/", "http://[::1]/", "http://169.254.169.254/latest/meta-data/", "http://10.0.0.1/", "http://192.168.0.10:8080/", "http://[::ffff:127.0.0.1]/", "http://2130706433/", // 127.0.0.1 em decimal: o parser do WHATWG normaliza ])("recusa %s", (url) => { expect(() => assertDownloadableUrl(url)).toThrow(/local or private/); }); it("libera endereços privados com ALLOW_PRIVATE_NETWORK=true", () => { process.env.ALLOW_PRIVATE_NETWORK = "true"; expect(assertDownloadableUrl("http://127.0.0.1/").hostname).toBe("127.0.0.1"); }); }); describe("createGuardedConnector", () => { const connect = (hostname: string) => new Promise((done) => { const connector = createGuardedConnector({ timeout: 2000 }); connector({ hostname, host: hostname, protocol: "http:", port: "9" }, (err, socket) => { socket?.destroy(); done(err); }); }); it("recusa IP literal privado antes de conectar", async () => { expect(await connect("127.0.0.1")).toBeInstanceOf(UrlNotAllowedError); }); it("recusa nome que resolve para IP privado", async () => { const err = await connect("internal.test"); expect(err).toBeInstanceOf(UrlNotAllowedError); expect(err?.message).toMatch(/internal\.test resolves/); }); it("deixa passar nome que resolve para IP público (falha só ao conectar)", async () => { const err = await connect("public.test"); expect(err).not.toBeInstanceOf(UrlNotAllowedError); }); }); describe("DownloadManager com a proteção", () => { let origin: Server | undefined; afterEach(async () => { if (origin) await new Promise((done) => origin!.close(done)); origin = undefined; }); async function startOrigin(handler: Parameters[1]) { origin = createServer(handler); await new Promise((done) => origin!.listen(0, "127.0.0.1", done)); return (origin.address() as AddressInfo).port; } async function waitWhileDownloading(read: () => string | undefined) { for (let i = 0; i < 500 && read() === "downloading"; i++) { await new Promise((done) => setTimeout(done, 5)); } } it("recusa enfileirar URL privada", async () => { const { sessionManager, DownloadManager, storageRoot } = await loadModules(); const manager = new DownloadManager(new Agent()); const session = sessionManager.getSession(); // loadModules() reimporta os módulos, então a classe não é a mesma: compara pelo nome. await expect(manager.queueDownload(session.id, "http://127.0.0.1:1/x")).rejects.toMatchObject({ name: "UrlNotAllowedError", }); expect(manager.getTasks(session.id)).toEqual([]); rmSync(storageRoot, { recursive: true, force: true }); }); it("segue redirect e salva o arquivo final, não o corpo do 302", async () => { process.env.ALLOW_PRIVATE_NETWORK = "true"; const port = await startOrigin((req, res) => { if (req.url === "/start") { res.writeHead(302, { location: "/final.bin" }); res.end("redirecting"); return; } res.setHeader("content-length", "5"); res.end("hello"); }); const { sessionManager, DownloadManager, storageRoot } = await loadModules(); const agent = new Agent({ connect: createGuardedConnector({ timeout: 2000 }) }).compose( interceptors.redirect({ maxRedirections: 5 }), ); const manager = new DownloadManager(agent); const session = sessionManager.getSession(); const task = await manager.queueDownload(session.id, `http://127.0.0.1:${port}/start`); await waitWhileDownloading(() => manager.getTask(task.id)?.status); const finished = manager.getTask(task.id)!; expect(finished.status).toBe("completed"); expect(finished.totalBytes).toBe(5); rmSync(storageRoot, { recursive: true, force: true }); }); it("marca erro quando a resposta 3xx não é seguida", async () => { process.env.ALLOW_PRIVATE_NETWORK = "true"; const port = await startOrigin((_req, res) => { res.writeHead(302); res.end("nope"); }); const { sessionManager, DownloadManager, storageRoot } = await loadModules(); const manager = new DownloadManager(new Agent()); const session = sessionManager.getSession(); const task = await manager.queueDownload(session.id, `http://127.0.0.1:${port}/`); await waitWhileDownloading(() => manager.getTask(task.id)?.status); expect(manager.getTask(task.id)).toMatchObject({ status: "error", error: "HTTP Error 302" }); rmSync(storageRoot, { recursive: true, force: true }); }); });