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

OpenClaw < 2026.2.22 - Symlink Traversal in Avatar Handling

CVE-2026-32024

Description

OpenClaw versions prior to 2026.2.22 contain a symlink traversal vulnerability in avatar handling that allows attackers to read arbitrary files outside the configured workspace boundary. Remote attackers can exploit this by requesting avatar resources through gateway surfaces to disclose local files accessible to the OpenClaw process.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1

Patches

2
6970c2c2db3e

fix(gateway): harden control-ui avatar reads

https://github.com/openclaw/openclawPeter SteinbergerFeb 22, 2026via ghsa
2 files changed · +114 13
  • src/gateway/control-ui.http.test.ts+61 1 modified
    @@ -4,7 +4,7 @@ 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 { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
     import { makeMockHttpResponse } from "./test-http-response.js";
     
     describe("handleControlUiHttpRequest", () => {
    @@ -58,6 +58,24 @@ describe("handleControlUiHttpRequest", () => {
         return { res, end, handled };
       }
     
    +  function runAvatarRequest(params: {
    +    url: string;
    +    method: "GET" | "HEAD";
    +    resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
    +    basePath?: string;
    +  }) {
    +    const { res, end } = makeMockHttpResponse();
    +    const handled = handleControlUiAvatarRequest(
    +      { url: params.url, method: params.method } as IncomingMessage,
    +      res,
    +      {
    +        ...(params.basePath ? { basePath: params.basePath } : {}),
    +        resolveAvatar: params.resolveAvatar,
    +      },
    +    );
    +    return { res, end, handled };
    +  }
    +
       async function writeAssetFile(rootPath: string, filename: string, contents: string) {
         const assetsDir = path.join(rootPath, "assets");
         await fs.mkdir(assetsDir, { recursive: true });
    @@ -179,6 +197,48 @@ describe("handleControlUiHttpRequest", () => {
         });
       });
     
    +  it("serves local avatar bytes through hardened avatar handler", async () => {
    +    const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-"));
    +    try {
    +      const avatarPath = path.join(tmp, "main.png");
    +      await fs.writeFile(avatarPath, "avatar-bytes\n");
    +
    +      const { res, end, handled } = runAvatarRequest({
    +        url: "/avatar/main",
    +        method: "GET",
    +        resolveAvatar: () => ({ kind: "local", filePath: avatarPath }),
    +      });
    +
    +      expect(handled).toBe(true);
    +      expect(res.statusCode).toBe(200);
    +      expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("avatar-bytes\n");
    +    } finally {
    +      await fs.rm(tmp, { recursive: true, force: true });
    +    }
    +  });
    +
    +  it("rejects avatar symlink paths from resolver", async () => {
    +    const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-link-"));
    +    const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-outside-"));
    +    try {
    +      const outsideFile = path.join(outside, "secret.txt");
    +      await fs.writeFile(outsideFile, "outside-secret\n");
    +      const linkPath = path.join(tmp, "avatar-link.png");
    +      await fs.symlink(outsideFile, linkPath);
    +
    +      const { res, end, handled } = runAvatarRequest({
    +        url: "/avatar/main",
    +        method: "GET",
    +        resolveAvatar: () => ({ kind: "local", filePath: linkPath }),
    +      });
    +
    +      expectNotFoundResponse({ handled, res, end });
    +    } finally {
    +      await fs.rm(tmp, { recursive: true, force: true });
    +      await fs.rm(outside, { recursive: true, force: true });
    +    }
    +  });
    +
       it("rejects symlinked assets that resolve outside control-ui root", async () => {
         await withControlUiRoot({
           fn: async (tmp) => {
    
  • src/gateway/control-ui.ts+53 12 modified
    @@ -4,6 +4,7 @@ import path from "node:path";
     import type { OpenClawConfig } from "../config/config.js";
     import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
     import { isWithinDir } from "../infra/path-safety.js";
    +import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
     import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
     import {
       CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
    @@ -162,16 +163,25 @@ export function handleControlUiAvatarRequest(
         return true;
       }
     
    -  if (req.method === "HEAD") {
    -    res.statusCode = 200;
    -    res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase()));
    -    res.setHeader("Cache-Control", "no-cache");
    -    res.end();
    +  const safeAvatar = resolveSafeAvatarFile(resolved.filePath);
    +  if (!safeAvatar) {
    +    respondNotFound(res);
         return true;
       }
    +  try {
    +    if (req.method === "HEAD") {
    +      res.statusCode = 200;
    +      res.setHeader("Content-Type", contentTypeForExt(path.extname(safeAvatar.path).toLowerCase()));
    +      res.setHeader("Cache-Control", "no-cache");
    +      res.end();
    +      return true;
    +    }
     
    -  serveFile(res, resolved.filePath);
    -  return true;
    +    serveResolvedFile(res, safeAvatar.path, fs.readFileSync(safeAvatar.fd));
    +    return true;
    +  } finally {
    +    fs.closeSync(safeAvatar.fd);
    +  }
     }
     
     function respondNotFound(res: ServerResponse) {
    @@ -188,11 +198,6 @@ function setStaticFileHeaders(res: ServerResponse, filePath: string) {
       res.setHeader("Cache-Control", "no-cache");
     }
     
    -function serveFile(res: ServerResponse, filePath: string) {
    -  setStaticFileHeaders(res, filePath);
    -  res.end(fs.readFileSync(filePath));
    -}
    -
     function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) {
       setStaticFileHeaders(res, filePath);
       res.end(body);
    @@ -219,6 +224,42 @@ function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
       return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
     }
     
    +function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | null {
    +  let fd: number | null = null;
    +  try {
    +    const candidateStat = fs.lstatSync(filePath);
    +    if (candidateStat.isSymbolicLink()) {
    +      return null;
    +    }
    +    const fileReal = fs.realpathSync(filePath);
    +    const preOpenStat = fs.lstatSync(fileReal);
    +    if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
    +      return null;
    +    }
    +    const openFlags =
    +      fs.constants.O_RDONLY |
    +      (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
    +    fd = fs.openSync(fileReal, openFlags);
    +    const openedStat = fs.fstatSync(fd);
    +    if (
    +      !openedStat.isFile() ||
    +      openedStat.size > AVATAR_MAX_BYTES ||
    +      !areSameFileIdentity(preOpenStat, openedStat)
    +    ) {
    +      return null;
    +    }
    +    const safeFile = { path: fileReal, fd };
    +    fd = null;
    +    return safeFile;
    +  } catch {
    +    return null;
    +  } finally {
    +    if (fd !== null) {
    +      fs.closeSync(fd);
    +    }
    +  }
    +}
    +
     function resolveSafeControlUiFile(
       rootReal: string,
       filePath: string,
    
3d0337504349

fix(gateway): block avatar symlink escapes

https://github.com/openclaw/openclawPeter SteinbergerFeb 22, 2026via ghsa
3 files changed · +102 7
  • CHANGELOG.md+1 0 modified
    @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
     - Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
     - Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
     - Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting.
    +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
     - Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
     - Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
     - Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
    
  • src/gateway/session-utils.test.ts+60 0 modified
    @@ -8,6 +8,7 @@ import {
       capArrayByJsonBytes,
       classifySessionKey,
       deriveSessionTitle,
    +  listAgentsForGateway,
       listSessionsFromStore,
       parseGroupKey,
       pruneLegacyStoreKeys,
    @@ -16,6 +17,19 @@ import {
       resolveSessionStoreKey,
     } from "./session-utils.js";
     
    +function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean {
    +  try {
    +    fs.symlinkSync(targetPath, linkPath);
    +    return true;
    +  } catch (error) {
    +    const code = (error as NodeJS.ErrnoException).code;
    +    if (process.platform === "win32" && (code === "EPERM" || code === "EACCES")) {
    +      return false;
    +    }
    +    throw error;
    +  }
    +}
    +
     describe("gateway session utils", () => {
       test("capArrayByJsonBytes trims from the front", () => {
         const res = capArrayByJsonBytes(["a", "b", "c"], 10);
    @@ -217,6 +231,52 @@ describe("gateway session utils", () => {
         });
         expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
       });
    +
    +  test("listAgentsForGateway rejects avatar symlink escapes outside workspace", () => {
    +    const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-outside-"));
    +    const workspace = path.join(root, "workspace");
    +    fs.mkdirSync(workspace, { recursive: true });
    +    const outsideFile = path.join(root, "outside.txt");
    +    fs.writeFileSync(outsideFile, "top-secret", "utf8");
    +    const linkPath = path.join(workspace, "avatar-link.png");
    +    if (!createSymlinkOrSkip(outsideFile, linkPath)) {
    +      return;
    +    }
    +
    +    const cfg = {
    +      session: { mainKey: "main" },
    +      agents: {
    +        list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
    +      },
    +    } as OpenClawConfig;
    +
    +    const result = listAgentsForGateway(cfg);
    +    expect(result.agents[0]?.identity?.avatarUrl).toBeUndefined();
    +  });
    +
    +  test("listAgentsForGateway allows avatar symlinks that stay inside workspace", () => {
    +    const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-inside-"));
    +    const workspace = path.join(root, "workspace");
    +    fs.mkdirSync(path.join(workspace, "avatars"), { recursive: true });
    +    const targetPath = path.join(workspace, "avatars", "actual.png");
    +    fs.writeFileSync(targetPath, "avatar", "utf8");
    +    const linkPath = path.join(workspace, "avatar-link.png");
    +    if (!createSymlinkOrSkip(targetPath, linkPath)) {
    +      return;
    +    }
    +
    +    const cfg = {
    +      session: { mainKey: "main" },
    +      agents: {
    +        list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
    +      },
    +    } as OpenClawConfig;
    +
    +    const result = listAgentsForGateway(cfg);
    +    expect(result.agents[0]?.identity?.avatarUrl).toBe(
    +      `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`,
    +    );
    +  });
     });
     
     describe("resolveSessionModelRef", () => {
    
  • src/gateway/session-utils.ts+41 7 modified
    @@ -66,6 +66,19 @@ export type {
     } from "./session-utils.types.js";
     
     const DERIVED_TITLE_MAX_LEN = 60;
    +
    +function tryResolveExistingPath(value: string): string | null {
    +  try {
    +    return fs.realpathSync(value);
    +  } catch {
    +    return null;
    +  }
    +}
    +
    +function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
    +  return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
    +}
    +
     function resolveIdentityAvatarUrl(
       cfg: OpenClawConfig,
       agentId: string,
    @@ -85,21 +98,42 @@ function resolveIdentityAvatarUrl(
         return undefined;
       }
       const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
    -  const workspaceRoot = path.resolve(workspaceDir);
    -  const resolved = path.resolve(workspaceRoot, trimmed);
    -  if (!isPathWithinRoot(workspaceRoot, resolved)) {
    +  const workspaceRoot = tryResolveExistingPath(workspaceDir) ?? path.resolve(workspaceDir);
    +  const resolvedCandidate = path.resolve(workspaceRoot, trimmed);
    +  if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
         return undefined;
       }
    +  let fd: number | null = null;
       try {
    -    const stat = fs.statSync(resolved);
    -    if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) {
    +    const resolvedReal = fs.realpathSync(resolvedCandidate);
    +    if (!isPathWithinRoot(workspaceRoot, resolvedReal)) {
    +      return undefined;
    +    }
    +    const preOpenStat = fs.lstatSync(resolvedReal);
    +    if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
           return undefined;
         }
    -    const buffer = fs.readFileSync(resolved);
    -    const mime = resolveAvatarMime(resolved);
    +    const openFlags =
    +      fs.constants.O_RDONLY |
    +      (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
    +    fd = fs.openSync(resolvedReal, openFlags);
    +    const openedStat = fs.fstatSync(fd);
    +    if (
    +      !openedStat.isFile() ||
    +      openedStat.size > AVATAR_MAX_BYTES ||
    +      !areSameFileIdentity(preOpenStat, openedStat)
    +    ) {
    +      return undefined;
    +    }
    +    const buffer = fs.readFileSync(fd);
    +    const mime = resolveAvatarMime(resolvedCandidate);
         return `data:${mime};base64,${buffer.toString("base64")}`;
       } catch {
         return undefined;
    +  } finally {
    +    if (fd !== null) {
    +      fs.closeSync(fd);
    +    }
       }
     }
     
    

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.