From 9a1fc96e2b9358d1d7f433baac05c38fe452f535 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 8 Jun 2026 13:53:58 -0500 Subject: [PATCH] small security fixes --- Overlord-Server/src/server/build-process.ts | 7 +- .../src/server/routes/auth-cookie.test.ts | 57 ++++++ .../src/server/routes/auth-cookie.ts | 28 ++- .../src/server/routes/auto-deploy-routes.ts | 7 +- .../src/server/routes/deploy-routes.ts | 47 ++--- .../src/server/routes/file-share-routes.ts | 7 +- .../src/server/routes/plugin-routes.ts | 8 +- .../src/server/routes/winre-routes.ts | 8 +- .../src/server/upload-security.test.ts | 33 ++++ Overlord-Server/src/server/upload-security.ts | 37 ++++ .../src/server/url-security.test.ts | 45 +++++ Overlord-Server/src/server/url-security.ts | 166 ++++++++++++++++++ 12 files changed, 395 insertions(+), 55 deletions(-) create mode 100644 Overlord-Server/src/server/routes/auth-cookie.test.ts create mode 100644 Overlord-Server/src/server/upload-security.test.ts create mode 100644 Overlord-Server/src/server/upload-security.ts create mode 100644 Overlord-Server/src/server/url-security.test.ts create mode 100644 Overlord-Server/src/server/url-security.ts diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index f0331cf..935dbc9 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -20,6 +20,7 @@ import { import { runDonut } from "./donut-manager"; import { buildLinuxShellcode } from "./linux-shellcode-manager"; import { runSgn } from "./sgn-manager"; +import { resolveContainedPath } from "./upload-security"; function isClientModuleDir(dir: string): boolean { return ( @@ -214,7 +215,7 @@ async function uploadBuildFilesToFileShare( for (const file of build.files as any[]) { const filename: string = file.filename || file.name; if (!filename) continue; - const sourcePath = path.join(outDir, filename); + const sourcePath = resolveContainedPath(outDir, filename); if (!fs.existsSync(sourcePath)) { sendToStream({ type: "output", @@ -225,9 +226,9 @@ async function uploadBuildFilesToFileShare( } try { const id = uuidv4(); - const folder = path.join(fileShareRoot, id); + const folder = resolveContainedPath(fileShareRoot, id); await fs.promises.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); await fs.promises.copyFile(sourcePath, targetPath); const size = fs.statSync(targetPath).size; diff --git a/Overlord-Server/src/server/routes/auth-cookie.test.ts b/Overlord-Server/src/server/routes/auth-cookie.test.ts new file mode 100644 index 0000000..e3f37a1 --- /dev/null +++ b/Overlord-Server/src/server/routes/auth-cookie.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { shouldUseSecureAuthCookie } from "./auth-cookie"; + +const ORIGINAL_ENV = { + OVERLORD_AUTH_COOKIE_SECURE: process.env.OVERLORD_AUTH_COOKIE_SECURE, + OVERLORD_TLS_OFFLOAD: process.env.OVERLORD_TLS_OFFLOAD, + OVERLORD_TRUST_PROXY_HEADERS: process.env.OVERLORD_TRUST_PROXY_HEADERS, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete (process.env as any)[key]; + else (process.env as any)[key] = value; + } +}); + +describe("shouldUseSecureAuthCookie", () => { + test("does not trust X-Forwarded-Proto by default", () => { + delete process.env.OVERLORD_AUTH_COOKIE_SECURE; + delete process.env.OVERLORD_TLS_OFFLOAD; + delete process.env.OVERLORD_TRUST_PROXY_HEADERS; + + const req = new Request("http://localhost/api/login", { + headers: { "x-forwarded-proto": "https" }, + }); + + expect(shouldUseSecureAuthCookie(req)).toBe(false); + }); + + test("treats TLS offload as externally secure without forwarded headers", () => { + process.env.OVERLORD_TLS_OFFLOAD = "true"; + delete process.env.OVERLORD_TRUST_PROXY_HEADERS; + delete process.env.OVERLORD_AUTH_COOKIE_SECURE; + + const req = new Request("http://localhost/api/login"); + + expect(shouldUseSecureAuthCookie(req)).toBe(true); + }); + + test("trusts X-Forwarded-Proto when proxy headers are explicitly trusted", () => { + process.env.OVERLORD_TRUST_PROXY_HEADERS = "true"; + delete process.env.OVERLORD_TLS_OFFLOAD; + delete process.env.OVERLORD_AUTH_COOKIE_SECURE; + + const req = new Request("http://localhost/api/login", { + headers: { "x-forwarded-proto": "https" }, + }); + + expect(shouldUseSecureAuthCookie(req)).toBe(true); + }); + + test("honors explicit secure override", () => { + process.env.OVERLORD_AUTH_COOKIE_SECURE = "true"; + const req = new Request("http://localhost/api/login"); + expect(shouldUseSecureAuthCookie(req)).toBe(true); + }); +}); diff --git a/Overlord-Server/src/server/routes/auth-cookie.ts b/Overlord-Server/src/server/routes/auth-cookie.ts index 50249a6..613a226 100644 --- a/Overlord-Server/src/server/routes/auth-cookie.ts +++ b/Overlord-Server/src/server/routes/auth-cookie.ts @@ -8,6 +8,12 @@ function envFlagDisabled(value: string): boolean { return v === "0" || v === "false" || v === "no" || v === "off"; } +function shouldTrustProxyHeaders(): boolean { + const explicit = String(process.env.OVERLORD_TRUST_PROXY_HEADERS || ""); + if (explicit) return envFlagEnabled(explicit); + return false; +} + export function shouldUseSecureAuthCookie(req: Request): boolean { const mode = String(process.env.OVERLORD_AUTH_COOKIE_SECURE || "auto"); if (envFlagEnabled(mode)) { @@ -17,16 +23,22 @@ export function shouldUseSecureAuthCookie(req: Request): boolean { return false; } - const forwardedProto = req.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim() - ?.toLowerCase(); - if (forwardedProto === "https") { + if (envFlagEnabled(String(process.env.OVERLORD_TLS_OFFLOAD || ""))) { return true; } - if (forwardedProto === "http") { - return false; + + if (shouldTrustProxyHeaders()) { + const forwardedProto = req.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + ?.toLowerCase(); + if (forwardedProto === "https") { + return true; + } + if (forwardedProto === "http") { + return false; + } } try { diff --git a/Overlord-Server/src/server/routes/auto-deploy-routes.ts b/Overlord-Server/src/server/routes/auto-deploy-routes.ts index d259448..150b3cd 100644 --- a/Overlord-Server/src/server/routes/auto-deploy-routes.ts +++ b/Overlord-Server/src/server/routes/auto-deploy-routes.ts @@ -13,6 +13,7 @@ import { requirePermission } from "../../rbac"; import { AUTO_DEPLOY_TRIGGERS, ALLOWED_OS_FILTERS } from "../validation-constants"; import { v4 as uuidv4 } from "uuid"; import type { DeployOs } from "../deploy-utils"; +import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security"; type AutoDeployRouteDeps = { DEPLOY_ROOT: string; @@ -89,12 +90,12 @@ export async function handleAutoDeployRoutes( return Response.json({ error: "Invalid trigger" }, { status: 400 }); } - const filename = path.basename(file.name || "upload.bin"); + const filename = sanitizeUploadFilename(file.name, "upload.bin"); const id = uuidv4(); await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true }); - const folder = path.join(deps.DEPLOY_ROOT, "auto", id); + const folder = resolveContainedPath(deps.DEPLOY_ROOT, "auto", id); await fs.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); const bytes = new Uint8Array(await file.arrayBuffer()); await fs.writeFile(targetPath, bytes); diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts index 9b15e9e..34c9d31 100644 --- a/Overlord-Server/src/server/routes/deploy-routes.ts +++ b/Overlord-Server/src/server/routes/deploy-routes.ts @@ -1,6 +1,5 @@ import { createHash } from "crypto"; import fs from "fs/promises"; -import path from "path"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; @@ -9,6 +8,8 @@ import { metrics } from "../../metrics"; import { encodeMessage } from "../../protocol"; import { requirePermission } from "../../rbac"; import { createUploadPull } from "../file-transfer-state"; +import { fetchPublicUrlBytes, validatePublicHttpUrl } from "../url-security"; +import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -64,12 +65,12 @@ export async function handleDeployRoutes( return new Response("Missing file", { status: 400 }); } - const filename = path.basename(file.name || "upload.bin"); + const filename = sanitizeUploadFilename(file.name, "upload.bin"); const id = uuidv4(); await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true }); - const folder = path.join(deps.DEPLOY_ROOT, id); + const folder = resolveContainedPath(deps.DEPLOY_ROOT, id); await fs.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); const bytes = new Uint8Array(await file.arrayBuffer()); await fs.writeFile(targetPath, bytes); @@ -112,47 +113,27 @@ export async function handleDeployRoutes( let parsed: URL; try { - parsed = new URL(fileUrl); - } catch { - return new Response("Invalid URL", { status: 400 }); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return new Response("Only http and https URLs are allowed", { status: 400 }); + parsed = await validatePublicHttpUrl(fileUrl); + } catch (err: any) { + return new Response(err?.message || "Invalid URL", { status: 400 }); } - const hostname = parsed.hostname.toLowerCase(); - const BLOCKED_HOSTS = ["localhost", "metadata.google.internal", "169.254.169.254"]; - if ( - BLOCKED_HOSTS.includes(hostname) || - hostname.endsWith(".internal") || - hostname.startsWith("127.") || - hostname === "[::1]" || - /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname) || - hostname.startsWith("169.254.") || - hostname.startsWith("0.") - ) { - return new Response("URLs pointing to private/internal addresses are not allowed", { status: 400 }); - } - - const rawFilename = path.basename(parsed.pathname) || "download.bin"; - const filename = rawFilename.replace(/[^a-zA-Z0-9._\-]/g, "_").substring(0, 128) || "download.bin"; + const rawFilename = parsed.pathname.split("/").pop() || "download.bin"; + const filename = sanitizeUploadFilename(rawFilename, "download.bin"); let fileBytes: Uint8Array; try { - const fetchRes = await fetch(fileUrl); - if (!fetchRes.ok) { - return new Response(`Remote fetch failed: ${fetchRes.status}`, { status: 502 }); - } - fileBytes = new Uint8Array(await fetchRes.arrayBuffer()); + const fetched = await fetchPublicUrlBytes(parsed.toString()); + fileBytes = fetched.bytes; } catch (err: any) { return new Response(`Failed to fetch URL: ${err?.message || "network error"}`, { status: 502 }); } const id = uuidv4(); await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true }); - const folder = path.join(deps.DEPLOY_ROOT, id); + const folder = resolveContainedPath(deps.DEPLOY_ROOT, id); await fs.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); await fs.writeFile(targetPath, fileBytes); const os = deps.detectUploadOs(filename, fileBytes); diff --git a/Overlord-Server/src/server/routes/file-share-routes.ts b/Overlord-Server/src/server/routes/file-share-routes.ts index 0b4e066..361ac35 100644 --- a/Overlord-Server/src/server/routes/file-share-routes.ts +++ b/Overlord-Server/src/server/routes/file-share-routes.ts @@ -13,6 +13,7 @@ import { incrementSharedFileDownloadCount, } from "../../db"; import { getUserById, canUploadFiles } from "../../users"; +import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security"; type FileShareRouteDeps = { FILE_SHARE_ROOT: string; @@ -191,13 +192,13 @@ export async function handleFileShareRoutes( return Response.json({ error: "Missing file" }, { status: 400 }); } - const filename = path.basename(file.name || "upload.bin"); + const filename = sanitizeUploadFilename(file.name, "upload.bin"); const id = uuidv4(); await fs.mkdir(deps.FILE_SHARE_ROOT, { recursive: true }); - const folder = path.join(deps.FILE_SHARE_ROOT, id); + const folder = resolveContainedPath(deps.FILE_SHARE_ROOT, id); await fs.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); const bytes = new Uint8Array(await file.arrayBuffer()); await fs.writeFile(targetPath, bytes); diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 81230a4..c2bd539 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -1262,7 +1262,13 @@ export async function handlePluginRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } - const [, pluginId, assetPath] = pluginAssetMatch; + const [, rawPluginId, assetPath] = pluginAssetMatch; + let pluginId = ""; + try { + pluginId = deps.sanitizePluginId(rawPluginId); + } catch { + return new Response("Invalid plugin id", { status: 400 }); + } try { requirePluginAccess(user, pluginId); } catch (error) { diff --git a/Overlord-Server/src/server/routes/winre-routes.ts b/Overlord-Server/src/server/routes/winre-routes.ts index 1ecbbc8..91b0119 100644 --- a/Overlord-Server/src/server/routes/winre-routes.ts +++ b/Overlord-Server/src/server/routes/winre-routes.ts @@ -1,5 +1,4 @@ import fs from "fs/promises"; -import path from "path"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; @@ -8,6 +7,7 @@ import { metrics } from "../../metrics"; import { encodeMessage } from "../../protocol"; import { requirePermission } from "../../rbac"; import { createUploadPull } from "../file-transfer-state"; +import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -100,12 +100,12 @@ export async function handleWinRERoutes( return new Response("Missing file", { status: 400 }); } - const filename = path.basename(file.name || "upload.exe"); + const filename = sanitizeUploadFilename(file.name, "upload.exe"); const id = uuidv4(); await fs.mkdir(deps.WINRE_ROOT, { recursive: true }); - const folder = path.join(deps.WINRE_ROOT, id); + const folder = resolveContainedPath(deps.WINRE_ROOT, id); await fs.mkdir(folder, { recursive: true }); - const targetPath = path.join(folder, filename); + const targetPath = resolveContainedPath(folder, filename); const bytes = new Uint8Array(await file.arrayBuffer()); await fs.writeFile(targetPath, bytes); diff --git a/Overlord-Server/src/server/upload-security.test.ts b/Overlord-Server/src/server/upload-security.test.ts new file mode 100644 index 0000000..953d518 --- /dev/null +++ b/Overlord-Server/src/server/upload-security.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import path from "path"; +import { resolveContainedPath, sanitizeUploadFilename } from "./upload-security"; + +describe("sanitizeUploadFilename", () => { + test("normalizes slash and backslash separated names", () => { + expect(sanitizeUploadFilename("../../payload.exe")).toBe("payload.exe"); + expect(sanitizeUploadFilename("..\\..\\payload.exe")).toBe("payload.exe"); + }); + + test("replaces unsafe characters and rejects dot-only names", () => { + expect(sanitizeUploadFilename("bad/name';.exe")).toBe("name__.exe"); + expect(sanitizeUploadFilename("..", "upload.bin")).toBe("upload.bin"); + expect(sanitizeUploadFilename(".", "upload.bin")).toBe("upload.bin"); + }); + + test("avoids Windows reserved device names", () => { + expect(sanitizeUploadFilename("CON")).toBe("_CON"); + expect(sanitizeUploadFilename("lpt1.txt")).toBe("_lpt1.txt"); + }); +}); + +describe("resolveContainedPath", () => { + test("allows paths inside root", () => { + const root = path.resolve("data/uploads"); + expect(resolveContainedPath(root, "abc", "file.bin")).toBe(path.resolve(root, "abc", "file.bin")); + }); + + test("rejects paths that escape root", () => { + const root = path.resolve("data/uploads"); + expect(() => resolveContainedPath(root, "..", "outside.bin")).toThrow(); + }); +}); diff --git a/Overlord-Server/src/server/upload-security.ts b/Overlord-Server/src/server/upload-security.ts new file mode 100644 index 0000000..6d9f5d0 --- /dev/null +++ b/Overlord-Server/src/server/upload-security.ts @@ -0,0 +1,37 @@ +import path from "path"; + +const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +export function sanitizeUploadFilename( + name: string | undefined | null, + fallback = "upload.bin", + maxLength = 128, +): string { + const fallbackName = fallback || "upload.bin"; + const raw = String(name || "").replace(/\\/g, "/").split("/").pop() || fallbackName; + const cleaned = raw + .trim() + .replace(/[^\w.\-() ]/g, "_") + .replace(/\s+/g, " ") + .slice(0, maxLength); + + if (!cleaned || cleaned === "." || cleaned === ".." || /^[. ]+$/.test(cleaned)) { + return fallbackName; + } + + const stem = cleaned.replace(/\.+$/, "").split(".")[0] || cleaned; + if (WINDOWS_RESERVED_NAMES.test(stem)) { + return `_${cleaned}`; + } + + return cleaned; +} + +export function resolveContainedPath(root: string, ...segments: string[]): string { + const resolvedRoot = path.resolve(root); + const resolvedPath = path.resolve(resolvedRoot, ...segments); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error("Resolved path escapes target root"); + } + return resolvedPath; +} diff --git a/Overlord-Server/src/server/url-security.test.ts b/Overlord-Server/src/server/url-security.test.ts new file mode 100644 index 0000000..980dd63 --- /dev/null +++ b/Overlord-Server/src/server/url-security.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { isPrivateOrLocalAddress, validatePublicHttpUrl } from "./url-security"; + +describe("isPrivateOrLocalAddress", () => { + test("flags local and private IPv4 ranges", () => { + expect(isPrivateOrLocalAddress("127.0.0.1")).toBe(true); + expect(isPrivateOrLocalAddress("10.1.2.3")).toBe(true); + expect(isPrivateOrLocalAddress("172.16.0.1")).toBe(true); + expect(isPrivateOrLocalAddress("192.168.1.1")).toBe(true); + expect(isPrivateOrLocalAddress("169.254.169.254")).toBe(true); + }); + + test("flags local and private IPv6 ranges", () => { + expect(isPrivateOrLocalAddress("::1")).toBe(true); + expect(isPrivateOrLocalAddress("fe80::1")).toBe(true); + expect(isPrivateOrLocalAddress("fd00::1")).toBe(true); + expect(isPrivateOrLocalAddress("::ffff:127.0.0.1")).toBe(true); + }); + + test("allows public addresses", () => { + expect(isPrivateOrLocalAddress("8.8.8.8")).toBe(false); + expect(isPrivateOrLocalAddress("2001:4860:4860::8888")).toBe(false); + }); +}); + +describe("validatePublicHttpUrl", () => { + test("rejects localhost and credentialed URLs", async () => { + await expect(validatePublicHttpUrl("http://localhost/file.bin")).rejects.toThrow(); + await expect(validatePublicHttpUrl("https://user:pass@example.com/file.bin")).rejects.toThrow(); + }); + + test("rejects hostnames that resolve to private addresses", async () => { + await expect( + validatePublicHttpUrl("https://example.test/file.bin", async () => [{ address: "10.0.0.5" }]), + ).rejects.toThrow("private/internal"); + }); + + test("allows hostnames that resolve to public addresses", async () => { + const parsed = await validatePublicHttpUrl( + "https://example.test/file.bin", + async () => [{ address: "203.0.113.10" }], + ); + expect(parsed.hostname).toBe("example.test"); + }); +}); diff --git a/Overlord-Server/src/server/url-security.ts b/Overlord-Server/src/server/url-security.ts new file mode 100644 index 0000000..4e6da30 --- /dev/null +++ b/Overlord-Server/src/server/url-security.ts @@ -0,0 +1,166 @@ +import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export const DEFAULT_FETCH_URL_MAX_BYTES = 250 * 1024 * 1024; + +type LookupAddress = { address: string }; +type LookupFn = (hostname: string) => Promise; + +function normalizeHost(hostname: string): string { + return hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); +} + +function ipv4ToBytes(address: string): number[] | null { + const parts = address.split("."); + if (parts.length !== 4) return null; + const bytes = parts.map((p) => Number(p)); + if (bytes.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return null; + return bytes; +} + +function isPrivateIPv4(address: string): boolean { + const b = ipv4ToBytes(address); + if (!b) return true; + const [a, c] = b; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && c >= 64 && c <= 127) || + (a === 169 && c === 254) || + (a === 172 && c >= 16 && c <= 31) || + (a === 192 && c === 168) || + (a === 192 && c === 0) || + (a === 198 && (c === 18 || c === 19)) || + a >= 224 + ); +} + +function isPrivateIPv6(address: string): boolean { + const host = normalizeHost(address); + if (host === "::" || host === "::1") return true; + if (host.startsWith("fe80:")) return true; + const firstHextet = Number.parseInt(host.split(":")[0] || "0", 16); + if (Number.isFinite(firstHextet) && (firstHextet & 0xfe00) === 0xfc00) return true; + + const mapped = host.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (mapped) return isPrivateIPv4(mapped[1]); + + return false; +} + +export function isPrivateOrLocalAddress(address: string): boolean { + const host = normalizeHost(address); + const family = isIP(host); + if (family === 4) return isPrivateIPv4(host); + if (family === 6) return isPrivateIPv6(host); + return false; +} + +async function defaultLookup(hostname: string): Promise { + return await dnsLookup(hostname, { all: true, verbatim: true }); +} + +export async function validatePublicHttpUrl( + rawUrl: string, + lookupFn: LookupFn = defaultLookup, +): Promise { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error("Invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only http and https URLs are allowed"); + } + if (parsed.username || parsed.password) { + throw new Error("URLs with embedded credentials are not allowed"); + } + + const hostname = normalizeHost(parsed.hostname); + if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".internal")) { + throw new Error("URLs pointing to private/internal addresses are not allowed"); + } + + const literalFamily = isIP(hostname); + const addresses = literalFamily ? [{ address: hostname }] : await lookupFn(hostname); + if (addresses.length === 0 || addresses.some((entry) => isPrivateOrLocalAddress(entry.address))) { + throw new Error("URLs pointing to private/internal addresses are not allowed"); + } + + return parsed; +} + +export function getFetchUrlMaxBytes(): number { + const parsed = Number(process.env.OVERLORD_DEPLOY_FETCH_MAX_BYTES); + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); + return DEFAULT_FETCH_URL_MAX_BYTES; +} + +export async function fetchPublicUrlBytes( + rawUrl: string, + maxBytes = getFetchUrlMaxBytes(), + lookupFn: LookupFn = defaultLookup, +): Promise<{ bytes: Uint8Array; finalUrl: URL }> { + let current = await validatePublicHttpUrl(rawUrl, lookupFn); + + for (let redirects = 0; redirects <= 3; redirects += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60_000); + let response: Response; + try { + response = await fetch(current, { + redirect: "manual", + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) throw new Error(`Remote fetch failed: ${response.status}`); + if (redirects === 3) throw new Error("Too many redirects"); + current = await validatePublicHttpUrl(new URL(location, current).toString(), lookupFn); + continue; + } + + if (!response.ok) { + throw new Error(`Remote fetch failed: ${response.status}`); + } + + const length = Number(response.headers.get("content-length") || 0); + if (Number.isFinite(length) && length > maxBytes) { + throw new Error(`Remote file exceeds ${maxBytes} byte limit`); + } + + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body?.getReader(); + if (!reader) return { bytes: new Uint8Array(), finalUrl: current }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + try { await reader.cancel(); } catch {} + throw new Error(`Remote file exceeds ${maxBytes} byte limit`); + } + chunks.push(value); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { bytes, finalUrl: current }; + } + + throw new Error("Too many redirects"); +}