security: harden gateway proxy, custom runtime proxy, and media routes (#95)

* security hardening pass 1 - otel removed

* hardening pass #2

* feat security hardening pass

* chore: trim unrelated docs from security hardening pr

* fix: address security hardening review findings

* address findings
This commit is contained in:
gsknnft
2026-04-03 18:02:06 -04:00
committed by GitHub
parent 083c146aac
commit 051d0ce469
14 changed files with 572 additions and 30 deletions
+28 -4
View File
@@ -62,6 +62,12 @@ const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime
return { resolved, mime };
};
const isWithinAllowedRoot = (targetPath: string, allowedRoot: string): boolean => {
const relative = path.relative(allowedRoot, targetPath);
if (!relative) return true;
return !relative.startsWith("..") && !path.isAbsolute(relative);
};
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
const { trimmed, mime } = validateRawMediaPath(raw);
@@ -83,15 +89,32 @@ const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: strin
return { remotePath: trimmed, mime };
};
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
const stat = await fs.stat(resolvedPath);
const readLocalMedia = async (
resolvedPath: string,
allowedRoot: string
): Promise<{ bytes: Buffer; size: number }> => {
const entry = await fs.lstat(resolvedPath);
if (entry.isSymbolicLink()) {
throw new Error("symlinked media paths are not allowed");
}
const [realResolvedPath, realAllowedRoot] = await Promise.all([
fs.realpath(resolvedPath),
fs.realpath(allowedRoot).catch(() => path.resolve(allowedRoot)),
]);
if (!isWithinAllowedRoot(realResolvedPath, realAllowedRoot)) {
throw new Error(`Refusing to read media outside ${realAllowedRoot}`);
}
const stat = await fs.stat(realResolvedPath);
if (!stat.isFile()) {
throw new Error("path is not a file");
}
if (stat.size > MAX_MEDIA_BYTES) {
throw new Error(`media file too large (${stat.size} bytes)`);
}
const buf = await fs.readFile(resolvedPath);
const buf = await fs.readFile(realResolvedPath);
return { bytes: buf, size: stat.size };
};
@@ -166,7 +189,8 @@ export async function GET(request: Request) {
if (!sshTarget) {
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
const { bytes, size } = await readLocalMedia(resolved);
const allowedRoot = path.join(os.homedir(), ".openclaw");
const { bytes, size } = await readLocalMedia(resolved, allowedRoot);
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
return new Response(body, {
headers: {
+55 -5
View File
@@ -9,6 +9,27 @@ type CustomRuntimeRequestBody = {
body?: unknown;
};
const isRuntimeUrlAllowed = (runtimeUrl: string): boolean => {
const rawAllowlist = (
process.env.CUSTOM_RUNTIME_ALLOWLIST ||
process.env.UPSTREAM_ALLOWLIST ||
""
).trim();
if (!rawAllowlist) {
return process.env.NODE_ENV !== "production";
}
try {
const parsed = new URL(runtimeUrl);
const allowed = rawAllowlist
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
return allowed.includes(parsed.hostname.toLowerCase());
} catch {
return false;
}
};
const normalizeRuntimeUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
@@ -25,7 +46,11 @@ const normalizeRuntimeUrl = (value: string): string => {
}
parsed.username = "";
parsed.password = "";
return parsed.toString().replace(/\/$/, "");
const normalized = parsed.toString().replace(/\/$/, "");
if (!isRuntimeUrlAllowed(normalized)) {
throw new Error("runtimeUrl is not in the allowed hosts list.");
}
return normalized;
};
const normalizePathname = (value: unknown): string => {
@@ -44,8 +69,18 @@ const normalizeMethod = (value: unknown): "GET" | "POST" => {
};
export async function POST(request: Request) {
let payload;
try {
payload = (await request.json()) as CustomRuntimeRequestBody;
} catch (error) {
console.error("[runtime/custom] Invalid JSON request body.", error);
return NextResponse.json(
{ error: "Invalid JSON request body." },
{ status: 400 }
);
}
try {
const payload = (await request.json()) as CustomRuntimeRequestBody;
const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? "");
const pathname = normalizePathname(payload.pathname);
const method = normalizeMethod(payload.method);
@@ -67,8 +102,23 @@ export async function POST(request: Request) {
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Custom runtime proxy failed.";
return NextResponse.json({ error: message }, { status: 400 });
const message = error instanceof Error ? error.message : "Custom runtime proxy failed.";
const status =
message === "runtimeUrl is required." ||
message === "pathname is required." ||
message === "runtimeUrl must use http, https, ws, or wss." ||
message === "runtimeUrl is not in the allowed hosts list."
? 400
: 502;
console.error("[runtime/custom] Proxy request failed.", error);
return NextResponse.json(
{
error:
status === 400
? message
: "Custom runtime proxy failed.",
},
{ status }
);
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { registerOTel } from "@vercel/otel";
// Telemetry intentionally disabled in this fork.
// Keep Next's instrumentation hook present, but do not register
// any external telemetry providers.
export const register = () => {
registerOTel({ serviceName: "claw3d" });
};
export const register = () => {};