fix(gateway): serve Control UI bootstrap config and lock down CSP

This commit is contained in:
Peter Steinberger
2026-02-16 03:04:47 +01:00
parent 568fd337be
commit adc818db4a
2 changed files with 113 additions and 70 deletions

View File

@@ -12,6 +12,7 @@ import {
} from "./control-ui-shared.js"; } from "./control-ui-shared.js";
const ROOT_PREFIX = "/"; const ROOT_PREFIX = "/";
const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json";
export type ControlUiRequestOptions = { export type ControlUiRequestOptions = {
basePath?: string; basePath?: string;
@@ -68,8 +69,24 @@ type ControlUiAvatarMeta = {
function applyControlUiSecurityHeaders(res: ServerResponse) { function applyControlUiSecurityHeaders(res: ServerResponse) {
res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); // Control UI: block framing, block inline scripts, keep styles permissive
// (UI uses a lot of inline style attributes in templates).
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss:",
].join("; "),
);
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
} }
function sendJson(res: ServerResponse, status: number, body: unknown) { function sendJson(res: ServerResponse, status: number, body: unknown) {
@@ -160,66 +177,10 @@ function serveFile(res: ServerResponse, filePath: string) {
res.end(fs.readFileSync(filePath)); res.end(fs.readFileSync(filePath));
} }
interface ControlUiInjectionOpts { function serveIndexHtml(res: ServerResponse, indexPath: string) {
basePath: string;
assistantName?: string;
assistantAvatar?: string;
}
function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string {
const { basePath, assistantName, assistantAvatar } = opts;
const script =
`<script>` +
`window.__OPENCLAW_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
`window.__OPENCLAW_ASSISTANT_NAME__=${JSON.stringify(
assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name,
)};` +
`window.__OPENCLAW_ASSISTANT_AVATAR__=${JSON.stringify(
assistantAvatar ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
)};` +
`</script>`;
// Check if already injected
if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) {
return html;
}
const headClose = html.indexOf("</head>");
if (headClose !== -1) {
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
}
return `${script}${html}`;
}
interface ServeIndexHtmlOpts {
basePath: string;
config?: OpenClawConfig;
agentId?: string;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8"); res.end(fs.readFileSync(indexPath, "utf8"));
res.end(
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
assistantAvatar: avatarValue,
}),
);
} }
function isSafeRelativePath(relPath: string) { function isSafeRelativePath(relPath: string) {
@@ -279,6 +240,35 @@ export function handleControlUiHttpRequest(
applyControlUiSecurityHeaders(res); applyControlUiSecurityHeaders(res);
const bootstrapConfigPath = basePath
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
if (pathname === bootstrapConfigPath) {
const config = opts?.config;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const avatarValue = resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: identity.agentId,
basePath,
});
if (req.method === "HEAD") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end();
return true;
}
sendJson(res, 200, {
basePath,
assistantName: identity.name,
assistantAvatar: avatarValue ?? identity.avatar,
assistantAgentId: identity.agentId,
});
return true;
}
const rootState = opts?.root; const rootState = opts?.root;
if (rootState?.kind === "invalid") { if (rootState?.kind === "invalid") {
res.statusCode = 503; res.statusCode = 503;
@@ -341,11 +331,7 @@ export function handleControlUiHttpRequest(
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
if (path.basename(filePath) === "index.html") { if (path.basename(filePath) === "index.html") {
serveIndexHtml(res, filePath, { serveIndexHtml(res, filePath);
basePath,
config: opts?.config,
agentId: opts?.agentId,
});
return true; return true;
} }
serveFile(res, filePath); serveFile(res, filePath);
@@ -355,11 +341,7 @@ export function handleControlUiHttpRequest(
// SPA fallback (client-side router): serve index.html for unknown paths. // SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html"); const indexPath = path.join(root, "index.html");
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
serveIndexHtml(res, indexPath, { serveIndexHtml(res, indexPath);
basePath,
config: opts?.config,
agentId: opts?.agentId,
});
return true; return true;
} }

View File

@@ -80,7 +80,68 @@ describe("handleControlUiHttpRequest", () => {
); );
expect(handled).toBe(true); expect(handled).toBe(true);
expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY");
expect(setHeader).toHaveBeenCalledWith("Content-Security-Policy", "frame-ancestors 'none'"); 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'");
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("does not inject inline scripts into index.html", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
try {
const html = "<html><head></head><body>Hello</body></html>\n";
await fs.writeFile(path.join(tmp, "index.html"), html);
const { res, end } = makeControlUiResponse();
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);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("serves bootstrap config JSON", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
try {
await fs.writeFile(path.join(tmp, "index.html"), "<html></html>\n");
const { res, end } = makeControlUiResponse();
const handled = handleControlUiHttpRequest(
{ url: "/__openclaw/control-ui-config.json", 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 payload = String(end.mock.calls[0]?.[0] ?? "");
const parsed = JSON.parse(payload) as {
basePath: string;
assistantName: string;
assistantAvatar: string;
assistantAgentId: string;
};
expect(parsed.basePath).toBe("");
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
expect(parsed.assistantAvatar).toBe("/avatar/main");
expect(parsed.assistantAgentId).toBe("main");
} finally { } finally {
await fs.rm(tmp, { recursive: true, force: true }); await fs.rm(tmp, { recursive: true, force: true });
} }