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
+15
-7
@@ -2,18 +2,18 @@ import Fastify, { FastifyRequest, FastifyReply } from "fastify";
|
|||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Agent, setGlobalDispatcher } from "undici";
|
import { Agent, interceptors, setGlobalDispatcher } from "undici";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { sessionManager } from "./lib/SessionManager.js";
|
import { sessionManager } from "./lib/SessionManager.js";
|
||||||
import { DownloadManager } from "./lib/DownloadManager.js";
|
import { DownloadManager } from "./lib/DownloadManager.js";
|
||||||
|
import { UrlNotAllowedError, createGuardedConnector } from "./lib/netGuard.js";
|
||||||
|
|
||||||
// Otimização: Agente HTTP global com conexões persistentes e timeout relaxado.
|
// Otimização: Agente HTTP global com conexões persistentes e timeout relaxado.
|
||||||
// Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket.
|
// Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket.
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
connect: {
|
// O conector recusa destinos locais/privados (SSRF), inclusive em redirects.
|
||||||
timeout: 30_000,
|
connect: createGuardedConnector({ timeout: 30_000 }),
|
||||||
},
|
|
||||||
pipelining: 1,
|
pipelining: 1,
|
||||||
keepAliveTimeout: 10_000,
|
keepAliveTimeout: 10_000,
|
||||||
connections: 500, // Aumenta limite de conexões simultâneas
|
connections: 500, // Aumenta limite de conexões simultâneas
|
||||||
@@ -51,7 +51,11 @@ app.get("/health", async () => {
|
|||||||
return { status: "ok" };
|
return { status: "ok" };
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadManager = new DownloadManager(agent);
|
// Segue até 5 redirects (links de download costumam redirecionar); sem isso o
|
||||||
|
// corpo da resposta 3xx era salvo como se fosse o arquivo.
|
||||||
|
const downloadManager = new DownloadManager(
|
||||||
|
agent.compose(interceptors.redirect({ maxRedirections: 5 })),
|
||||||
|
);
|
||||||
|
|
||||||
// Helper to get session from header
|
// Helper to get session from header
|
||||||
const getSession = (req: FastifyRequest, reply: FastifyReply) => {
|
const getSession = (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
@@ -85,8 +89,12 @@ app.post("/api/queue", async (request, reply) => {
|
|||||||
const parse = queueSchema.safeParse(request.body);
|
const parse = queueSchema.safeParse(request.body);
|
||||||
if (!parse.success) return reply.status(400).send({ error: "Invalid URL" });
|
if (!parse.success) return reply.status(400).send({ error: "Invalid URL" });
|
||||||
|
|
||||||
const task = await downloadManager.queueDownload(session.id, parse.data.url);
|
try {
|
||||||
return task;
|
return await downloadManager.queueDownload(session.id, parse.data.url);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UrlNotAllowedError) return reply.status(400).send({ error: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. List Tasks (Polling)
|
// 3. List Tasks (Polling)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Transform } from "node:stream";
|
|||||||
import { request, Dispatcher } from "undici";
|
import { request, Dispatcher } from "undici";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { sessionManager } from "./SessionManager.js";
|
import { sessionManager } from "./SessionManager.js";
|
||||||
|
import { assertDownloadableUrl } from "./netGuard.js";
|
||||||
|
|
||||||
type DownloadStatus = "queued" | "downloading" | "completed" | "error";
|
type DownloadStatus = "queued" | "downloading" | "completed" | "error";
|
||||||
|
|
||||||
@@ -28,6 +29,16 @@ export type DownloadTask = {
|
|||||||
// mapa não cresce sem limite num servidor de longa duração.
|
// mapa não cresce sem limite num servidor de longa duração.
|
||||||
const tasks = new Map<string, DownloadTask>();
|
const tasks = new Map<string, DownloadTask>();
|
||||||
|
|
||||||
|
// decodeURIComponent lança em sequências inválidas (ex.: "%E0%A4%A"); nesse caso
|
||||||
|
// mantém o texto original em vez de falhar o download inteiro.
|
||||||
|
function safeDecode(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
|
|
||||||
constructor(private globalAgent: Dispatcher) {
|
constructor(private globalAgent: Dispatcher) {
|
||||||
@@ -49,15 +60,13 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async queueDownload(sessionId: string, url: string): Promise<DownloadTask> {
|
public async queueDownload(sessionId: string, url: string): Promise<DownloadTask> {
|
||||||
|
const parsedUrl = assertDownloadableUrl(url);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
|
||||||
// Try to determine filename
|
// Try to determine filename
|
||||||
let filename = "download.bin";
|
let filename = "download.bin";
|
||||||
try {
|
const lastSegment = parsedUrl.pathname.split("/").pop();
|
||||||
const u = new URL(url);
|
if (lastSegment) filename = basename(lastSegment);
|
||||||
const p = u.pathname.split("/").pop();
|
|
||||||
if (p) filename = basename(p);
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
const task: DownloadTask = {
|
const task: DownloadTask = {
|
||||||
id,
|
id,
|
||||||
@@ -96,7 +105,7 @@ export class DownloadManager {
|
|||||||
headers: { "User-Agent": "Ephemeral-Downloader/2.0" }
|
headers: { "User-Agent": "Ephemeral-Downloader/2.0" }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.statusCode >= 400) {
|
if (response.statusCode >= 300) {
|
||||||
throw new Error(`HTTP Error ${response.statusCode}`);
|
throw new Error(`HTTP Error ${response.statusCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +116,7 @@ export class DownloadManager {
|
|||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
// O header vem do host de origem: usar apenas o nome base evita
|
// O header vem do host de origem: usar apenas o nome base evita
|
||||||
// caminho relativo (../) escrevendo fora do diretório da sessão.
|
// caminho relativo (../) escrevendo fora do diretório da sessão.
|
||||||
const headerName = basename(decodeURIComponent(match[1].replace(/"/g, "")));
|
const headerName = basename(safeDecode(match[1].replace(/"/g, "")));
|
||||||
if (headerName) {
|
if (headerName) {
|
||||||
task.filename = headerName;
|
task.filename = headerName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const STORAGE_ROOT = process.env.STORAGE_ROOT
|
|||||||
: resolve(SERVER_ROOT, "storage");
|
: resolve(SERVER_ROOT, "storage");
|
||||||
const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour
|
const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour
|
||||||
|
|
||||||
|
// O id vem do header x-session-id e vira nome de diretório: aceitar só UUID
|
||||||
|
// impede path traversal (por exemplo "..", que apagaria server/ na expiração).
|
||||||
|
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
type SessionData = {
|
type SessionData = {
|
||||||
id: string;
|
id: string;
|
||||||
lastActive: number;
|
lastActive: number;
|
||||||
@@ -41,6 +45,8 @@ export class SessionManager {
|
|||||||
public getSession(id?: string): SessionData {
|
public getSession(id?: string): SessionData {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (id !== undefined && !SESSION_ID_PATTERN.test(id)) id = undefined;
|
||||||
|
|
||||||
if (id && this.sessions.has(id)) {
|
if (id && this.sessions.has(id)) {
|
||||||
const session = this.sessions.get(id)!;
|
const session = this.sessions.get(id)!;
|
||||||
session.lastActive = now;
|
session.lastActive = now;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { lookup as dnsLookup } from "node:dns";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { buildConnector } from "undici";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proteção contra SSRF: o servidor baixa qualquer URL que o cliente enviar,
|
||||||
|
* então por padrão ele recusa endereços de loopback, rede privada, link-local
|
||||||
|
* (inclui o endpoint de metadados de nuvem 169.254.169.254) e similares.
|
||||||
|
*
|
||||||
|
* Para rodar o servidor numa rede privada e baixar de hosts internos de
|
||||||
|
* propósito, defina ALLOW_PRIVATE_NETWORK=true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UrlNotAllowedError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "UrlNotAllowedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowPrivateNetwork = () => process.env.ALLOW_PRIVATE_NETWORK === "true";
|
||||||
|
|
||||||
|
function parseIPv4(ip: string): number[] | null {
|
||||||
|
const parts = ip.split(".").map(Number);
|
||||||
|
return parts.length === 4 && parts.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)
|
||||||
|
? parts
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIPv4([a, b, c]: number[]): boolean {
|
||||||
|
return (
|
||||||
|
a === 0 || // "esta rede"
|
||||||
|
a === 10 || // privada
|
||||||
|
a === 127 || // loopback
|
||||||
|
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
||||||
|
(a === 169 && b === 254) || // link-local / metadados de nuvem
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || // privada
|
||||||
|
(a === 192 && b === 0 && c === 0) || // IETF protocol assignments
|
||||||
|
(a === 192 && b === 168) || // privada
|
||||||
|
(a === 198 && (b === 18 || b === 19)) || // benchmarking
|
||||||
|
a >= 224 // multicast, reservado e broadcast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expande um IPv6 para 8 grupos de 16 bits (ou null se for inválido). */
|
||||||
|
function parseIPv6(ip: string): number[] | null {
|
||||||
|
let text = ip.split("%")[0].toLowerCase(); // remove zone id
|
||||||
|
const embedded = text.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (embedded) {
|
||||||
|
const v4 = parseIPv4(embedded[2]);
|
||||||
|
if (!v4) return null;
|
||||||
|
text = `${embedded[1]}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const halves = text.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const head = halves[0] ? halves[0].split(":") : [];
|
||||||
|
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||||
|
const missing = 8 - head.length - tail.length;
|
||||||
|
if (missing < (halves.length === 2 ? 1 : 0) || (halves.length === 1 && missing !== 0)) return null;
|
||||||
|
|
||||||
|
const groups = [...head, ...Array(missing).fill("0"), ...tail].map((g) => parseInt(g, 16));
|
||||||
|
return groups.length === 8 && groups.every((g) => g >= 0 && g <= 0xffff) ? groups : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIPv6(ip: string): boolean {
|
||||||
|
const g = parseIPv6(ip);
|
||||||
|
if (!g) return true; // não conseguiu interpretar: recusa por segurança
|
||||||
|
|
||||||
|
const allZeroUntil = (n: number) => g.slice(0, n).every((x) => x === 0);
|
||||||
|
const v4FromTail = () => [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff];
|
||||||
|
|
||||||
|
if (allZeroUntil(7) && g[7] <= 1) return true; // :: e ::1
|
||||||
|
if (allZeroUntil(5) && g[5] === 0xffff) return isBlockedIPv4(v4FromTail()); // ::ffff:a.b.c.d
|
||||||
|
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) {
|
||||||
|
return isBlockedIPv4(v4FromTail()); // NAT64 64:ff9b::/96
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(g[0] & 0xfe00) === 0xfc00 || // fc00::/7 (unique local)
|
||||||
|
(g[0] & 0xffc0) === 0xfe80 || // fe80::/10 (link-local)
|
||||||
|
(g[0] & 0xff00) === 0xff00 // multicast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true se o endereço IP (v4 ou v6) pertence a uma faixa não pública. */
|
||||||
|
export function isBlockedAddress(ip: string): boolean {
|
||||||
|
const family = isIP(ip);
|
||||||
|
if (family === 4) return isBlockedIPv4(parseIPv4(ip)!);
|
||||||
|
if (family === 6) return isBlockedIPv6(ip);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedHostname(hostname: string): boolean {
|
||||||
|
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||||
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||||
|
return isIP(host) !== 0 && isBlockedAddress(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida a URL antes de enfileirar: só http/https e, a menos que
|
||||||
|
* ALLOW_PRIVATE_NETWORK=true, nada de localhost ou IP privado literal.
|
||||||
|
* Nomes de domínio que resolvem para IP privado são barrados na conexão.
|
||||||
|
*/
|
||||||
|
export function assertDownloadableUrl(rawUrl: string): URL {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new UrlNotAllowedError("Invalid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new UrlNotAllowedError("Only http and https URLs are allowed");
|
||||||
|
}
|
||||||
|
if (!allowPrivateNetwork() && isBlockedHostname(url.hostname)) {
|
||||||
|
throw new UrlNotAllowedError("URLs pointing to local or private addresses are not allowed");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector do undici que revalida o destino em cada conexão (inclusive nos
|
||||||
|
* redirects): IP literal é checado antes de conectar e nomes são checados no
|
||||||
|
* IP já resolvido, o que também cobre DNS rebinding.
|
||||||
|
*/
|
||||||
|
export function createGuardedConnector(options: buildConnector.BuildOptions): buildConnector.connector {
|
||||||
|
const connect = buildConnector({
|
||||||
|
...options,
|
||||||
|
lookup: (hostname, lookupOptions, callback) => {
|
||||||
|
dnsLookup(hostname, lookupOptions, (err, address, family) => {
|
||||||
|
if (err) return callback(err, address as never, family as never);
|
||||||
|
const list = Array.isArray(address) ? address.map((a) => a.address) : [address];
|
||||||
|
const blocked = !allowPrivateNetwork() && list.some(isBlockedAddress);
|
||||||
|
if (blocked) {
|
||||||
|
return callback(
|
||||||
|
new UrlNotAllowedError(`${hostname} resolves to a local or private address`),
|
||||||
|
address as never,
|
||||||
|
family as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
callback(null, address as never, family as never);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as buildConnector.BuildOptions);
|
||||||
|
|
||||||
|
return (opts, callback) => {
|
||||||
|
if (!allowPrivateNetwork() && isBlockedHostname(opts.hostname)) {
|
||||||
|
callback(new UrlNotAllowedError("Blocked local or private address"), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(opts, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||||
|
import { sep } from "node:path";
|
||||||
import { Agent } from "undici";
|
import { Agent } from "undici";
|
||||||
import { loadModules } from "./helpers.js";
|
import { loadModules } from "./helpers.js";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ let origin: Server;
|
|||||||
let originUrl: string;
|
let originUrl: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
process.env.ALLOW_PRIVATE_NETWORK = "true"; // a origem de teste está em 127.0.0.1
|
||||||
vi.useFakeTimers({ toFake: ["setInterval", "Date"] });
|
vi.useFakeTimers({ toFake: ["setInterval", "Date"] });
|
||||||
origin = createServer((_req, res) => {
|
origin = createServer((_req, res) => {
|
||||||
res.setHeader("content-length", "5");
|
res.setHeader("content-length", "5");
|
||||||
@@ -21,6 +23,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
delete process.env.ALLOW_PRIVATE_NETWORK;
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
await new Promise((done) => origin.close(done));
|
await new Promise((done) => origin.close(done));
|
||||||
});
|
});
|
||||||
@@ -72,3 +75,32 @@ describe("tasks e sessões", () => {
|
|||||||
rmSync(storageRoot, { recursive: true, force: true });
|
rmSync(storageRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("id de sessão", () => {
|
||||||
|
it("aceita um UUID já conhecido", async () => {
|
||||||
|
const { sessionManager, storageRoot } = await loadModules();
|
||||||
|
const first = sessionManager.getSession();
|
||||||
|
expect(sessionManager.getSession(first.id).id).toBe(first.id);
|
||||||
|
rmSync(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["..", ".", "../../etc", "a/b", "not-a-uuid", ""])(
|
||||||
|
"ignora o id inválido %j e cria outra sessão",
|
||||||
|
async (bad) => {
|
||||||
|
const { sessionManager, storageRoot } = await loadModules();
|
||||||
|
const session = sessionManager.getSession(bad);
|
||||||
|
expect(session.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(sessionManager.getSessionPath(session.id)).toBe(`${storageRoot}${sep}${session.id}`);
|
||||||
|
rmSync(storageRoot, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("não deixa um id '..' apagar a pasta pai quando expira", async () => {
|
||||||
|
const { sessionManager, storageRoot } = await loadModules();
|
||||||
|
sessionManager.getSession("..");
|
||||||
|
vi.setSystemTime(Date.now() + HOUR + 1000);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(existsSync(storageRoot)).toBe(true);
|
||||||
|
rmSync(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
type SessionData = {
|
type SessionData = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -63,7 +63,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// Poll Tasks
|
// Poll Tasks
|
||||||
const refreshTasks = async () => {
|
const refreshTasks = useCallback(async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/tasks`, {
|
const res = await fetch(`${API_URL}/api/tasks`, {
|
||||||
@@ -76,7 +76,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch tasks", err);
|
console.error("Failed to fetch tasks", err);
|
||||||
}
|
}
|
||||||
};
|
}, [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
@@ -87,7 +87,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Poll every 2 seconds
|
// Poll every 2 seconds
|
||||||
const interval = setInterval(refreshTasks, 2000);
|
const interval = setInterval(refreshTasks, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [sessionId]);
|
}, [sessionId, refreshTasks]);
|
||||||
|
|
||||||
const queueDownload = async (url: string) => {
|
const queueDownload = async (url: string) => {
|
||||||
if (!sessionId) throw new Error("Session not ready, try again in a moment");
|
if (!sessionId) throw new Error("Session not ready, try again in a moment");
|
||||||
|
|||||||
Reference in new issue
Block a user