fix(server): bloqueia SSRF e path traversal na sessão e segue redirects
- SSRF: /api/queue aceitava qualquer URL, inclusive localhost, rede privada e 169.254.169.254. Agora só http/https e, por padrão, nada de endereço local/privado (checagem na fila e no conector do undici, que cobre DNS rebinding e redirects). ALLOW_PRIVATE_NETWORK=true libera. - O header x-session-id virava nome de diretório sem validação; com ".." a expiração faria rmSync em server/. Agora só UUID é aceito. - Respostas 3xx eram salvas como se fossem o arquivo; agora segue até 5 redirects e trata 3xx restante como erro. - decodeURIComponent inválido no Content-Disposition derrubava o download. - web: useCallback em refreshTasks (corrige o warning exhaustive-deps).
This commit is contained in:
1 parent
bc9a43d6de
commit
dee0771ac5
7 files changed
+414
-18
No files matched your search
@@ -0,0 +1,188 @@
|
||||
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<Error | null>((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<typeof createServer>[1]) {
|
||||
origin = createServer(handler);
|
||||
await new Promise<void>((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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user