mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 20:51:23 +00:00
fix(gateway): serve Control UI bootstrap config and lock down CSP
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
} from "./control-ui-shared.js";
|
||||
|
||||
const ROOT_PREFIX = "/";
|
||||
const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json";
|
||||
|
||||
export type ControlUiRequestOptions = {
|
||||
basePath?: string;
|
||||
@@ -68,8 +69,24 @@ type ControlUiAvatarMeta = {
|
||||
|
||||
function applyControlUiSecurityHeaders(res: ServerResponse) {
|
||||
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("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||
@@ -160,66 +177,10 @@ function serveFile(res: ServerResponse, filePath: string) {
|
||||
res.end(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
interface ControlUiInjectionOpts {
|
||||
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;
|
||||
function serveIndexHtml(res: ServerResponse, indexPath: string) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
const raw = fs.readFileSync(indexPath, "utf8");
|
||||
res.end(
|
||||
injectControlUiConfig(raw, {
|
||||
basePath,
|
||||
assistantName: identity.name,
|
||||
assistantAvatar: avatarValue,
|
||||
}),
|
||||
);
|
||||
res.end(fs.readFileSync(indexPath, "utf8"));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(relPath: string) {
|
||||
@@ -279,6 +240,35 @@ export function handleControlUiHttpRequest(
|
||||
|
||||
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;
|
||||
if (rootState?.kind === "invalid") {
|
||||
res.statusCode = 503;
|
||||
@@ -341,11 +331,7 @@ export function handleControlUiHttpRequest(
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
if (path.basename(filePath) === "index.html") {
|
||||
serveIndexHtml(res, filePath, {
|
||||
basePath,
|
||||
config: opts?.config,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
serveIndexHtml(res, filePath);
|
||||
return true;
|
||||
}
|
||||
serveFile(res, filePath);
|
||||
@@ -355,11 +341,7 @@ export function handleControlUiHttpRequest(
|
||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||
const indexPath = path.join(root, "index.html");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
serveIndexHtml(res, indexPath, {
|
||||
basePath,
|
||||
config: opts?.config,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
serveIndexHtml(res, indexPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,68 @@ describe("handleControlUiHttpRequest", () => {
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
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 {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user