small security fixes

This commit is contained in:
KingKDot
2026-06-08 13:53:58 -05:00
parent 9fcb607902
commit 9a1fc96e2b
12 changed files with 395 additions and 55 deletions
+4 -3
View File
@@ -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");
});
});
+166
View File
@@ -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");
}