From 6ac89757ba5e45835bee5a2ba932a507319e4f5d Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Sat, 21 Feb 2026 15:47:51 -0700 Subject: [PATCH] Security/Gateway: harden Control UI static path containment (#21203) * Security/Gateway: harden Control UI static path containment * gateway: block control-ui symlink escapes * CI: retrigger flaky node test lane --------- Co-authored-by: Brian Mendonca --- src/gateway/control-ui.http.test.ts | 71 +++++++++++++++++++++++++++++ src/gateway/control-ui.ts | 8 +++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c36934069..2ba91404e 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -231,4 +231,75 @@ describe("handleControlUiHttpRequest", () => { }, }); }); + + it("rejects absolute-path escape attempts under basePath routes", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-root-")); + try { + const root = path.join(tmp, "ui"); + const sibling = path.join(tmp, "ui-secrets"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(sibling, { recursive: true }); + await fs.writeFile(path.join(root, "index.html"), "ok\n"); + const secretPath = path.join(sibling, "secret.txt"); + await fs.writeFile(secretPath, "sensitive-data"); + + const secretPathUrl = secretPath.split(path.sep).join("/"); + const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`; + const { res, end } = makeMockHttpResponse(); + + const handled = handleControlUiHttpRequest( + { url: `/openclaw/${absolutePathUrl}`, method: "GET" } as IncomingMessage, + res, + { + basePath: "/openclaw", + root: { kind: "resolved", path: root }, + }, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + expect(end).toHaveBeenCalledWith("Not Found"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("rejects symlink escape attempts under basePath routes", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-root-")); + try { + const root = path.join(tmp, "ui"); + const sibling = path.join(tmp, "outside"); + await fs.mkdir(path.join(root, "assets"), { recursive: true }); + await fs.mkdir(sibling, { recursive: true }); + await fs.writeFile(path.join(root, "index.html"), "ok\n"); + const secretPath = path.join(sibling, "secret.txt"); + await fs.writeFile(secretPath, "sensitive-data"); + + const linkPath = path.join(root, "assets", "leak.txt"); + try { + await fs.symlink(secretPath, linkPath, "file"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return; + } + throw error; + } + + const { res, end } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/openclaw/assets/leak.txt", method: "GET" } as IncomingMessage, + res, + { + basePath: "/openclaw", + root: { kind: "resolved", path: root }, + }, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + expect(end).toHaveBeenCalledWith("Not Found"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 85a68caf8..4dc5752d7 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; +import { isWithinDir } from "../infra/path-safety.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, @@ -264,6 +265,9 @@ function isSafeRelativePath(relPath: string) { return false; } const normalized = path.posix.normalize(relPath); + if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) { + return false; + } if (normalized.startsWith("../") || normalized === "..") { return false; } @@ -418,8 +422,8 @@ export function handleControlUiHttpRequest( return true; } - const filePath = path.join(root, fileRel); - if (!filePath.startsWith(root)) { + const filePath = path.resolve(root, fileRel); + if (!isWithinDir(root, filePath)) { respondNotFound(res); return true; }