From dee0771ac5d234b44e572a34506d60c15df6a626 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 03:59:44 -0300 Subject: [PATCH] =?UTF-8?q?fix(server):=20bloqueia=20SSRF=20e=20path=20tra?= =?UTF-8?q?versal=20na=20sess=C3=A3o=20e=20segue=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- server/src/index.ts | 22 ++-- server/src/lib/DownloadManager.ts | 23 ++-- server/src/lib/SessionManager.ts | 6 + server/src/lib/netGuard.ts | 153 +++++++++++++++++++++++ server/test/netGuard.test.ts | 188 +++++++++++++++++++++++++++++ server/test/sessions.test.ts | 32 +++++ web/src/context/SessionContext.tsx | 8 +- 7 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 server/src/lib/netGuard.ts create mode 100644 server/test/netGuard.test.ts diff --git a/server/src/index.ts b/server/src/index.ts index 5d71b61..42cdb43 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,18 +2,18 @@ import Fastify, { FastifyRequest, FastifyReply } from "fastify"; import cors from "@fastify/cors"; import fastifyStatic from "@fastify/static"; import { z } from "zod"; -import { Agent, setGlobalDispatcher } from "undici"; +import { Agent, interceptors, setGlobalDispatcher } from "undici"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { sessionManager } from "./lib/SessionManager.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. // Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket. const agent = new Agent({ - connect: { - timeout: 30_000, - }, + // O conector recusa destinos locais/privados (SSRF), inclusive em redirects. + connect: createGuardedConnector({ timeout: 30_000 }), pipelining: 1, keepAliveTimeout: 10_000, connections: 500, // Aumenta limite de conexões simultâneas @@ -51,7 +51,11 @@ app.get("/health", async () => { 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 const getSession = (req: FastifyRequest, reply: FastifyReply) => { @@ -85,8 +89,12 @@ app.post("/api/queue", async (request, reply) => { const parse = queueSchema.safeParse(request.body); if (!parse.success) return reply.status(400).send({ error: "Invalid URL" }); - const task = await downloadManager.queueDownload(session.id, parse.data.url); - return task; + try { + 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) diff --git a/server/src/lib/DownloadManager.ts b/server/src/lib/DownloadManager.ts index 12e897c..bae0fd5 100644 --- a/server/src/lib/DownloadManager.ts +++ b/server/src/lib/DownloadManager.ts @@ -5,6 +5,7 @@ import { Transform } from "node:stream"; import { request, Dispatcher } from "undici"; import { randomUUID } from "node:crypto"; import { sessionManager } from "./SessionManager.js"; +import { assertDownloadableUrl } from "./netGuard.js"; 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. const tasks = new Map(); +// 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 { constructor(private globalAgent: Dispatcher) { @@ -49,15 +60,13 @@ export class DownloadManager { } public async queueDownload(sessionId: string, url: string): Promise { + const parsedUrl = assertDownloadableUrl(url); const id = randomUUID(); // Try to determine filename let filename = "download.bin"; - try { - const u = new URL(url); - const p = u.pathname.split("/").pop(); - if (p) filename = basename(p); - } catch { } + const lastSegment = parsedUrl.pathname.split("/").pop(); + if (lastSegment) filename = basename(lastSegment); const task: DownloadTask = { id, @@ -96,7 +105,7 @@ export class DownloadManager { headers: { "User-Agent": "Ephemeral-Downloader/2.0" } }); - if (response.statusCode >= 400) { + if (response.statusCode >= 300) { throw new Error(`HTTP Error ${response.statusCode}`); } @@ -107,7 +116,7 @@ export class DownloadManager { if (match?.[1]) { // O header vem do host de origem: usar apenas o nome base evita // 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) { task.filename = headerName; } diff --git a/server/src/lib/SessionManager.ts b/server/src/lib/SessionManager.ts index 2c02b78..b65ef72 100644 --- a/server/src/lib/SessionManager.ts +++ b/server/src/lib/SessionManager.ts @@ -14,6 +14,10 @@ const STORAGE_ROOT = process.env.STORAGE_ROOT : resolve(SERVER_ROOT, "storage"); 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 = { id: string; lastActive: number; @@ -41,6 +45,8 @@ export class SessionManager { public getSession(id?: string): SessionData { const now = Date.now(); + if (id !== undefined && !SESSION_ID_PATTERN.test(id)) id = undefined; + if (id && this.sessions.has(id)) { const session = this.sessions.get(id)!; session.lastActive = now; diff --git a/server/src/lib/netGuard.ts b/server/src/lib/netGuard.ts new file mode 100644 index 0000000..c451ef5 --- /dev/null +++ b/server/src/lib/netGuard.ts @@ -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); + }; +} diff --git a/server/test/netGuard.test.ts b/server/test/netGuard.test.ts new file mode 100644 index 0000000..6e4d991 --- /dev/null +++ b/server/test/netGuard.test.ts @@ -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((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 }); + }); +}); diff --git a/server/test/sessions.test.ts b/server/test/sessions.test.ts index a93ca00..5fe19e2 100644 --- a/server/test/sessions.test.ts +++ b/server/test/sessions.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { existsSync, readdirSync, rmSync } from "node:fs"; +import { sep } from "node:path"; import { Agent } from "undici"; import { loadModules } from "./helpers.js"; @@ -11,6 +12,7 @@ let origin: Server; let originUrl: string; beforeEach(async () => { + process.env.ALLOW_PRIVATE_NETWORK = "true"; // a origem de teste está em 127.0.0.1 vi.useFakeTimers({ toFake: ["setInterval", "Date"] }); origin = createServer((_req, res) => { res.setHeader("content-length", "5"); @@ -21,6 +23,7 @@ beforeEach(async () => { }); afterEach(async () => { + delete process.env.ALLOW_PRIVATE_NETWORK; vi.useRealTimers(); await new Promise((done) => origin.close(done)); }); @@ -72,3 +75,32 @@ describe("tasks e sessões", () => { 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 }); + }); +}); diff --git a/web/src/context/SessionContext.tsx b/web/src/context/SessionContext.tsx index 6f5c11d..bffbc66 100644 --- a/web/src/context/SessionContext.tsx +++ b/web/src/context/SessionContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; type SessionData = { sessionId: string; @@ -63,7 +63,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }, [sessionId]); // Poll Tasks - const refreshTasks = async () => { + const refreshTasks = useCallback(async () => { if (!sessionId) return; try { const res = await fetch(`${API_URL}/api/tasks`, { @@ -76,7 +76,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { } catch (err) { console.error("Failed to fetch tasks", err); } - }; + }, [sessionId]); useEffect(() => { if (!sessionId) return; @@ -87,7 +87,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { // Poll every 2 seconds const interval = setInterval(refreshTasks, 2000); return () => clearInterval(interval); - }, [sessionId]); + }, [sessionId, refreshTasks]); const queueDownload = async (url: string) => { if (!sessionId) throw new Error("Session not ready, try again in a moment");