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:
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
Reference in New Issue
Block a user