small security fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<LookupAddress[]>;
|
||||
|
||||
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<LookupAddress[]> {
|
||||
return await dnsLookup(hostname, { all: true, verbatim: true });
|
||||
}
|
||||
|
||||
export async function validatePublicHttpUrl(
|
||||
rawUrl: string,
|
||||
lookupFn: LookupFn = defaultLookup,
|
||||
): Promise<URL> {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user