small security fixes
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
import { runDonut } from "./donut-manager";
|
import { runDonut } from "./donut-manager";
|
||||||
import { buildLinuxShellcode } from "./linux-shellcode-manager";
|
import { buildLinuxShellcode } from "./linux-shellcode-manager";
|
||||||
import { runSgn } from "./sgn-manager";
|
import { runSgn } from "./sgn-manager";
|
||||||
|
import { resolveContainedPath } from "./upload-security";
|
||||||
|
|
||||||
function isClientModuleDir(dir: string): boolean {
|
function isClientModuleDir(dir: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -214,7 +215,7 @@ async function uploadBuildFilesToFileShare(
|
|||||||
for (const file of build.files as any[]) {
|
for (const file of build.files as any[]) {
|
||||||
const filename: string = file.filename || file.name;
|
const filename: string = file.filename || file.name;
|
||||||
if (!filename) continue;
|
if (!filename) continue;
|
||||||
const sourcePath = path.join(outDir, filename);
|
const sourcePath = resolveContainedPath(outDir, filename);
|
||||||
if (!fs.existsSync(sourcePath)) {
|
if (!fs.existsSync(sourcePath)) {
|
||||||
sendToStream({
|
sendToStream({
|
||||||
type: "output",
|
type: "output",
|
||||||
@@ -225,9 +226,9 @@ async function uploadBuildFilesToFileShare(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const folder = path.join(fileShareRoot, id);
|
const folder = resolveContainedPath(fileShareRoot, id);
|
||||||
await fs.promises.mkdir(folder, { recursive: true });
|
await fs.promises.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
await fs.promises.copyFile(sourcePath, targetPath);
|
await fs.promises.copyFile(sourcePath, targetPath);
|
||||||
const size = fs.statSync(targetPath).size;
|
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";
|
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 {
|
export function shouldUseSecureAuthCookie(req: Request): boolean {
|
||||||
const mode = String(process.env.OVERLORD_AUTH_COOKIE_SECURE || "auto");
|
const mode = String(process.env.OVERLORD_AUTH_COOKIE_SECURE || "auto");
|
||||||
if (envFlagEnabled(mode)) {
|
if (envFlagEnabled(mode)) {
|
||||||
@@ -17,16 +23,22 @@ export function shouldUseSecureAuthCookie(req: Request): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const forwardedProto = req.headers
|
if (envFlagEnabled(String(process.env.OVERLORD_TLS_OFFLOAD || ""))) {
|
||||||
.get("x-forwarded-proto")
|
|
||||||
?.split(",")[0]
|
|
||||||
?.trim()
|
|
||||||
?.toLowerCase();
|
|
||||||
if (forwardedProto === "https") {
|
|
||||||
return true;
|
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 {
|
try {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { requirePermission } from "../../rbac";
|
|||||||
import { AUTO_DEPLOY_TRIGGERS, ALLOWED_OS_FILTERS } from "../validation-constants";
|
import { AUTO_DEPLOY_TRIGGERS, ALLOWED_OS_FILTERS } from "../validation-constants";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { DeployOs } from "../deploy-utils";
|
import type { DeployOs } from "../deploy-utils";
|
||||||
|
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
|
||||||
|
|
||||||
type AutoDeployRouteDeps = {
|
type AutoDeployRouteDeps = {
|
||||||
DEPLOY_ROOT: string;
|
DEPLOY_ROOT: string;
|
||||||
@@ -89,12 +90,12 @@ export async function handleAutoDeployRoutes(
|
|||||||
return Response.json({ error: "Invalid trigger" }, { status: 400 });
|
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();
|
const id = uuidv4();
|
||||||
await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
|
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 });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
await fs.writeFile(targetPath, bytes);
|
await fs.writeFile(targetPath, bytes);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
@@ -9,6 +8,8 @@ import { metrics } from "../../metrics";
|
|||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
import { createUploadPull } from "../file-transfer-state";
|
import { createUploadPull } from "../file-transfer-state";
|
||||||
|
import { fetchPublicUrlBytes, validatePublicHttpUrl } from "../url-security";
|
||||||
|
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -64,12 +65,12 @@ export async function handleDeployRoutes(
|
|||||||
return new Response("Missing file", { status: 400 });
|
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();
|
const id = uuidv4();
|
||||||
await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
|
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 });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
await fs.writeFile(targetPath, bytes);
|
await fs.writeFile(targetPath, bytes);
|
||||||
|
|
||||||
@@ -112,47 +113,27 @@ export async function handleDeployRoutes(
|
|||||||
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(fileUrl);
|
parsed = await validatePublicHttpUrl(fileUrl);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
return new Response("Invalid URL", { status: 400 });
|
return new Response(err?.message || "Invalid URL", { status: 400 });
|
||||||
}
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
||||||
return new Response("Only http and https URLs are allowed", { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
const rawFilename = parsed.pathname.split("/").pop() || "download.bin";
|
||||||
const BLOCKED_HOSTS = ["localhost", "metadata.google.internal", "169.254.169.254"];
|
const filename = sanitizeUploadFilename(rawFilename, "download.bin");
|
||||||
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";
|
|
||||||
|
|
||||||
let fileBytes: Uint8Array;
|
let fileBytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
const fetchRes = await fetch(fileUrl);
|
const fetched = await fetchPublicUrlBytes(parsed.toString());
|
||||||
if (!fetchRes.ok) {
|
fileBytes = fetched.bytes;
|
||||||
return new Response(`Remote fetch failed: ${fetchRes.status}`, { status: 502 });
|
|
||||||
}
|
|
||||||
fileBytes = new Uint8Array(await fetchRes.arrayBuffer());
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return new Response(`Failed to fetch URL: ${err?.message || "network error"}`, { status: 502 });
|
return new Response(`Failed to fetch URL: ${err?.message || "network error"}`, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
|
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 });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
await fs.writeFile(targetPath, fileBytes);
|
await fs.writeFile(targetPath, fileBytes);
|
||||||
|
|
||||||
const os = deps.detectUploadOs(filename, fileBytes);
|
const os = deps.detectUploadOs(filename, fileBytes);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
incrementSharedFileDownloadCount,
|
incrementSharedFileDownloadCount,
|
||||||
} from "../../db";
|
} from "../../db";
|
||||||
import { getUserById, canUploadFiles } from "../../users";
|
import { getUserById, canUploadFiles } from "../../users";
|
||||||
|
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
|
||||||
|
|
||||||
type FileShareRouteDeps = {
|
type FileShareRouteDeps = {
|
||||||
FILE_SHARE_ROOT: string;
|
FILE_SHARE_ROOT: string;
|
||||||
@@ -191,13 +192,13 @@ export async function handleFileShareRoutes(
|
|||||||
return Response.json({ error: "Missing file" }, { status: 400 });
|
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();
|
const id = uuidv4();
|
||||||
|
|
||||||
await fs.mkdir(deps.FILE_SHARE_ROOT, { recursive: true });
|
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 });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
|
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
await fs.writeFile(targetPath, bytes);
|
await fs.writeFile(targetPath, bytes);
|
||||||
|
|||||||
@@ -1262,7 +1262,13 @@ export async function handlePluginRoutes(
|
|||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
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 {
|
try {
|
||||||
requirePluginAccess(user, pluginId);
|
requirePluginAccess(user, pluginId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
@@ -8,6 +7,7 @@ import { metrics } from "../../metrics";
|
|||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
import { createUploadPull } from "../file-transfer-state";
|
import { createUploadPull } from "../file-transfer-state";
|
||||||
|
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -100,12 +100,12 @@ export async function handleWinRERoutes(
|
|||||||
return new Response("Missing file", { status: 400 });
|
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();
|
const id = uuidv4();
|
||||||
await fs.mkdir(deps.WINRE_ROOT, { recursive: true });
|
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 });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
const targetPath = path.join(folder, filename);
|
const targetPath = resolveContainedPath(folder, filename);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
await fs.writeFile(targetPath, bytes);
|
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