VYPR
Moderate severityNVD Advisory· Published Feb 19, 2026· Updated Feb 20, 2026

OpenClaw affected by Stored XSS in Control UI via unsanitized assistant name/avatar in inline script injection

CVE-2026-27009

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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.152026.2.15

Affected products

1

Patches

2
3b4096e02e7e

fix(ui): load Control UI bootstrap config via JSON endpoint

https://github.com/openclaw/openclawPeter SteinbergerFeb 16, 2026via ghsa
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.
    +  }
    +}
    
adc818db4a4b

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

https://github.com/openclaw/openclawPeter SteinbergerFeb 16, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.