312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { IncomingMessage } from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
|
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
|
import { makeMockHttpResponse } from "./test-http-response.js";
|
|
|
|
describe("handleControlUiHttpRequest", () => {
|
|
async function withControlUiRoot<T>(params: {
|
|
indexHtml?: string;
|
|
fn: (tmp: string) => Promise<T>;
|
|
}) {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
|
|
try {
|
|
await fs.writeFile(path.join(tmp, "index.html"), params.indexHtml ?? "<html></html>\n");
|
|
return await params.fn(tmp);
|
|
} finally {
|
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function parseBootstrapPayload(end: ReturnType<typeof makeMockHttpResponse>["end"]) {
|
|
return JSON.parse(String(end.mock.calls[0]?.[0] ?? "")) as {
|
|
basePath: string;
|
|
assistantName: string;
|
|
assistantAvatar: string;
|
|
assistantAgentId: string;
|
|
};
|
|
}
|
|
|
|
function expectNotFoundResponse(params: {
|
|
handled: boolean;
|
|
res: ReturnType<typeof makeMockHttpResponse>["res"];
|
|
end: ReturnType<typeof makeMockHttpResponse>["end"];
|
|
}) {
|
|
expect(params.handled).toBe(true);
|
|
expect(params.res.statusCode).toBe(404);
|
|
expect(params.end).toHaveBeenCalledWith("Not Found");
|
|
}
|
|
|
|
async function withBasePathRootFixture<T>(params: {
|
|
siblingDir: string;
|
|
fn: (paths: { root: string; sibling: string }) => Promise<T>;
|
|
}) {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-root-"));
|
|
try {
|
|
const root = path.join(tmp, "ui");
|
|
const sibling = path.join(tmp, params.siblingDir);
|
|
await fs.mkdir(root, { recursive: true });
|
|
await fs.mkdir(sibling, { recursive: true });
|
|
await fs.writeFile(path.join(root, "index.html"), "<html>ok</html>\n");
|
|
return await params.fn({ root, sibling });
|
|
} finally {
|
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
it("sets security headers for Control UI responses", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const { res, setHeader } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/", method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
},
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY");
|
|
const csp = setHeader.mock.calls.find((call) => call[0] === "Content-Security-Policy")?.[1];
|
|
expect(typeof csp).toBe("string");
|
|
expect(String(csp)).toContain("frame-ancestors 'none'");
|
|
expect(String(csp)).toContain("script-src 'self'");
|
|
expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'");
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not inject inline scripts into index.html", async () => {
|
|
const html = "<html><head></head><body>Hello</body></html>\n";
|
|
await withControlUiRoot({
|
|
indexHtml: html,
|
|
fn: async (tmp) => {
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/", method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
config: {
|
|
agents: { defaults: { workspace: tmp } },
|
|
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "evil.png" } },
|
|
},
|
|
},
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(end).toHaveBeenCalledWith(html);
|
|
},
|
|
});
|
|
});
|
|
|
|
it("serves bootstrap config JSON", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
config: {
|
|
agents: { defaults: { workspace: tmp } },
|
|
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" } },
|
|
},
|
|
},
|
|
);
|
|
expect(handled).toBe(true);
|
|
const parsed = parseBootstrapPayload(end);
|
|
expect(parsed.basePath).toBe("");
|
|
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
|
|
expect(parsed.assistantAvatar).toBe("/avatar/main");
|
|
expect(parsed.assistantAgentId).toBe("main");
|
|
},
|
|
});
|
|
});
|
|
|
|
it("serves bootstrap config JSON under basePath", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
basePath: "/openclaw",
|
|
root: { kind: "resolved", path: tmp },
|
|
config: {
|
|
agents: { defaults: { workspace: tmp } },
|
|
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
|
},
|
|
},
|
|
);
|
|
expect(handled).toBe(true);
|
|
const parsed = parseBootstrapPayload(end);
|
|
expect(parsed.basePath).toBe("/openclaw");
|
|
expect(parsed.assistantName).toBe("Ops");
|
|
expect(parsed.assistantAvatar).toBe("/openclaw/avatar/main");
|
|
expect(parsed.assistantAgentId).toBe("main");
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects symlinked assets that resolve outside control-ui root", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const assetsDir = path.join(tmp, "assets");
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-outside-"));
|
|
try {
|
|
const outsideFile = path.join(outsideDir, "secret.txt");
|
|
await fs.mkdir(assetsDir, { recursive: true });
|
|
await fs.writeFile(outsideFile, "outside-secret\n");
|
|
await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt"));
|
|
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/assets/leak.txt", method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
},
|
|
);
|
|
expectNotFoundResponse({ handled, res, end });
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
});
|
|
});
|
|
|
|
it("allows symlinked assets that resolve inside control-ui root", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const assetsDir = path.join(tmp, "assets");
|
|
await fs.mkdir(assetsDir, { recursive: true });
|
|
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
|
|
await fs.symlink(path.join(assetsDir, "actual.txt"), path.join(assetsDir, "linked.txt"));
|
|
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/assets/linked.txt", method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
},
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("inside-ok\n");
|
|
},
|
|
});
|
|
});
|
|
|
|
it("serves HEAD for in-root assets without writing a body", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const assetsDir = path.join(tmp, "assets");
|
|
await fs.mkdir(assetsDir, { recursive: true });
|
|
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
|
|
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
},
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(end.mock.calls[0]?.length ?? -1).toBe(0);
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects symlinked SPA fallback index.html outside control-ui root", async () => {
|
|
await withControlUiRoot({
|
|
fn: async (tmp) => {
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-outside-"));
|
|
try {
|
|
const outsideIndex = path.join(outsideDir, "index.html");
|
|
await fs.writeFile(outsideIndex, "<html>outside</html>\n");
|
|
await fs.rm(path.join(tmp, "index.html"));
|
|
await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
|
|
|
|
const { res, end } = makeMockHttpResponse();
|
|
const handled = handleControlUiHttpRequest(
|
|
{ url: "/app/route", method: "GET" } as IncomingMessage,
|
|
res,
|
|
{
|
|
root: { kind: "resolved", path: tmp },
|
|
},
|
|
);
|
|
expectNotFoundResponse({ handled, res, end });
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects absolute-path escape attempts under basePath routes", async () => {
|
|
await withBasePathRootFixture({
|
|
siblingDir: "ui-secrets",
|
|
fn: async ({ root, sibling }) => {
|
|
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 },
|
|
},
|
|
);
|
|
expectNotFoundResponse({ handled, res, end });
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects symlink escape attempts under basePath routes", async () => {
|
|
await withBasePathRootFixture({
|
|
siblingDir: "outside",
|
|
fn: async ({ root, sibling }) => {
|
|
await fs.mkdir(path.join(root, "assets"), { recursive: true });
|
|
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 },
|
|
},
|
|
);
|
|
expectNotFoundResponse({ handled, res, end });
|
|
},
|
|
});
|
|
});
|
|
});
|