OpenClaw affected by Stored XSS in Control UI via unsanitized assistant name/avatar in inline script injection
Description
OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a atored XSS issue in the OpenClaw Control UI when rendering assistant identity (name/avatar) into an inline <script> tag without script-context-safe escaping. A crafted value containing </script> could break out of the script tag and execute attacker-controlled JavaScript in the Control UI origin. Version 2026.2.15 removed inline script injection and serve bootstrap config from a JSON endpoint and added a restrictive Content Security Policy for the Control UI (script-src 'self', no inline scripts).
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.15 | 2026.2.15 |
Affected products
1Patches
23b4096e02e7efix(ui): load Control UI bootstrap config via JSON endpoint
5 files changed · +122 −22
ui/src/ui/app-lifecycle.ts+5 −0 modified@@ -17,10 +17,14 @@ import { syncTabWithLocation, syncThemeWithSettings, } from "./app-settings.ts"; +import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; type LifecycleHost = { basePath: string; tab: Tab; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; chatHasAutoScrolled: boolean; chatManualRefreshInFlight: boolean; chatLoading: boolean; @@ -36,6 +40,7 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + void loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]); syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true); syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
ui/src/ui/app.ts+5 −5 modified@@ -76,7 +76,7 @@ import { type ToolStreamEntry, type CompactionStatus, } from "./app-tool-stream.ts"; -import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts"; +import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; @@ -87,7 +87,7 @@ declare global { } } -const injectedAssistantIdentity = resolveInjectedAssistantIdentity(); +const bootAssistantIdentity = normalizeAssistantIdentity({}); function resolveOnboardingMode(): boolean { if (!window.location.search) { @@ -118,9 +118,9 @@ export class OpenClawApp extends LitElement { private toolStreamSyncTimer: number | null = null; private sidebarCloseTimer: number | null = null; - @state() assistantName = injectedAssistantIdentity.name; - @state() assistantAvatar = injectedAssistantIdentity.avatar; - @state() assistantAgentId = injectedAssistantIdentity.agentId ?? null; + @state() assistantName = bootAssistantIdentity.name; + @state() assistantAvatar = bootAssistantIdentity.avatar; + @state() assistantAgentId = bootAssistantIdentity.agentId ?? null; @state() sessionKey = this.settings.sessionKey; @state() chatLoading = false;
ui/src/ui/assistant-identity.ts+0 −17 modified@@ -10,13 +10,6 @@ export type AssistantIdentity = { avatar: string | null; }; -declare global { - interface Window { - __OPENCLAW_ASSISTANT_NAME__?: string; - __OPENCLAW_ASSISTANT_AVATAR__?: string; - } -} - function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; @@ -40,13 +33,3 @@ export function normalizeAssistantIdentity( typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null; return { agentId, name, avatar }; } - -export function resolveInjectedAssistantIdentity(): AssistantIdentity { - if (typeof window === "undefined") { - return normalizeAssistantIdentity({}); - } - return normalizeAssistantIdentity({ - name: window.__OPENCLAW_ASSISTANT_NAME__, - avatar: window.__OPENCLAW_ASSISTANT_AVATAR__, - }); -}
ui/src/ui/controllers/control-ui-bootstrap.test.ts+60 −0 added@@ -0,0 +1,60 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import { loadControlUiBootstrapConfig } from "./control-ui-bootstrap.ts"; + +describe("loadControlUiBootstrapConfig", () => { + it("loads assistant identity from the bootstrap endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "/openclaw", + assistantName: "Ops", + assistantAvatar: "O", + assistantAgentId: "main", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "/openclaw", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + "/openclaw/__openclaw/control-ui-config.json", + expect.objectContaining({ method: "GET" }), + ); + expect(state.assistantName).toBe("Ops"); + expect(state.assistantAvatar).toBe("O"); + expect(state.assistantAgentId).toBe("main"); + + vi.unstubAllGlobals(); + }); + + it("ignores failures", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + "/__openclaw/control-ui-config.json", + expect.objectContaining({ method: "GET" }), + ); + expect(state.assistantName).toBe("Assistant"); + + vi.unstubAllGlobals(); + }); +});
ui/src/ui/controllers/control-ui-bootstrap.ts+52 −0 added@@ -0,0 +1,52 @@ +import { normalizeAssistantIdentity } from "../assistant-identity.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +type ControlUiBootstrapConfig = { + basePath?: string; + assistantName?: string; + assistantAvatar?: string; + assistantAgentId?: string; +}; + +export type ControlUiBootstrapState = { + basePath: string; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; +}; + +export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { + if (typeof window === "undefined") { + return; + } + if (typeof fetch !== "function") { + return; + } + + const basePath = normalizeBasePath(state.basePath ?? ""); + const url = basePath + ? `${basePath}/__openclaw/control-ui-config.json` + : "/__openclaw/control-ui-config.json"; + + try { + const res = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!res.ok) { + return; + } + const parsed = (await res.json()) as ControlUiBootstrapConfig; + const normalized = normalizeAssistantIdentity({ + agentId: parsed.assistantAgentId ?? null, + name: parsed.assistantName, + avatar: parsed.assistantAvatar ?? null, + }); + state.assistantName = normalized.name; + state.assistantAvatar = normalized.avatar; + state.assistantAgentId = normalized.agentId ?? null; + } catch { + // Ignore bootstrap failures; UI will update identity after connecting. + } +}
adc818db4a4bfix(gateway): serve Control UI bootstrap config and lock down CSP
2 files changed · +113 −70
src/gateway/control-ui.ts+51 −69 modified@@ -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; }
src/gateway/gateway-misc.test.ts+62 −1 modified@@ -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 }); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-37gc-85xm-2ww6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27009ghsaADVISORY
- github.com/openclaw/openclaw/commit/3b4096e02e7e335f99f5986ec1bd566e90b14a7eghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/adc818db4a4b3b8d663e7674ef20436947514e1bghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.15ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-37gc-85xm-2ww6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.