gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)

Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
This commit is contained in:
Radek Sienkiewicz
2026-03-09 01:50:42 +01:00
committed by GitHub
parent 13bd3db307
commit 4f42c03a49
9 changed files with 404 additions and 41 deletions

View File

@@ -45,6 +45,7 @@ describe("handleControlUiHttpRequest", () => {
method: "GET" | "HEAD" | "POST";
rootPath: string;
basePath?: string;
rootKind?: "resolved" | "bundled";
}) {
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
@@ -52,7 +53,7 @@ describe("handleControlUiHttpRequest", () => {
res,
{
...(params.basePath ? { basePath: params.basePath } : {}),
root: { kind: "resolved", path: params.rootPath },
root: { kind: params.rootKind ?? "resolved", path: params.rootPath },
},
);
return { res, end, handled };
@@ -326,6 +327,72 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("rejects hardlinked index.html for non-package control-ui roots", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-hardlink-"));
try {
const outsideIndex = path.join(outsideDir, "index.html");
await fs.writeFile(outsideIndex, "<html>outside-hardlink</html>\n");
await fs.rm(path.join(tmp, "index.html"));
await fs.link(outsideIndex, path.join(tmp, "index.html"));
const { res, end, handled } = runControlUiRequest({
url: "/",
method: "GET",
rootPath: tmp,
});
expectNotFoundResponse({ handled, res, end });
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
},
});
});
it("rejects hardlinked asset files for custom/resolved roots (security boundary)", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
const { res, end, handled } = runControlUiRequest({
url: "/assets/app.hl.js",
method: "GET",
rootPath: tmp,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
expect(end).toHaveBeenCalledWith("Not Found");
},
});
});
it("serves hardlinked asset files for bundled roots (pnpm global install)", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');");
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
const { res, end, handled } = runControlUiRequest({
url: "/assets/app.hl.js",
method: "GET",
rootPath: tmp,
rootKind: "bundled",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');");
},
});
});
it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => {
await withControlUiRoot({
fn: async (tmp) => {