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

OpenClaw < 2026.2.22 - Arbitrary File Read via Symlink Following in Static File Handler

CVE-2026-32020

Description

OpenClaw versions prior to 2026.2.22 contain a path traversal vulnerability in the static file handler that follows symbolic links, allowing out-of-root file reads. Attackers can place symlinks under the Control UI root directory to bypass directory confinement checks and read arbitrary files outside the intended root.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1

Patches

1
7c500ff6236f

fix(security): harden control-ui static path resolution

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
2 files changed · +155 8
  • src/gateway/control-ui.http.test.ts+83 0 modified
    @@ -125,4 +125,87 @@ describe("handleControlUiHttpRequest", () => {
           },
         });
       });
    +
    +  it("rejects symlinked assets that resolve outside control-ui root", async () => {
    +    await withControlUiRoot({
    +      fn: async (tmp) => {
    +        const assetsDir = path.join(tmp, "assets");
    +        const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-outside-"));
    +        try {
    +          const outsideFile = path.join(outsideDir, "secret.txt");
    +          await fs.mkdir(assetsDir, { recursive: true });
    +          await fs.writeFile(outsideFile, "outside-secret\n");
    +          await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt"));
    +
    +          const { res, end } = makeMockHttpResponse();
    +          const handled = handleControlUiHttpRequest(
    +            { url: "/assets/leak.txt", method: "GET" } as IncomingMessage,
    +            res,
    +            {
    +              root: { kind: "resolved", path: tmp },
    +            },
    +          );
    +
    +          expect(handled).toBe(true);
    +          expect(res.statusCode).toBe(404);
    +          expect(end).toHaveBeenCalledWith("Not Found");
    +        } finally {
    +          await fs.rm(outsideDir, { recursive: true, force: true });
    +        }
    +      },
    +    });
    +  });
    +
    +  it("allows symlinked assets that resolve inside control-ui root", async () => {
    +    await withControlUiRoot({
    +      fn: async (tmp) => {
    +        const assetsDir = path.join(tmp, "assets");
    +        await fs.mkdir(assetsDir, { recursive: true });
    +        await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
    +        await fs.symlink(path.join(assetsDir, "actual.txt"), path.join(assetsDir, "linked.txt"));
    +
    +        const { res, end } = makeMockHttpResponse();
    +        const handled = handleControlUiHttpRequest(
    +          { url: "/assets/linked.txt", method: "GET" } as IncomingMessage,
    +          res,
    +          {
    +            root: { kind: "resolved", path: tmp },
    +          },
    +        );
    +
    +        expect(handled).toBe(true);
    +        expect(res.statusCode).toBe(200);
    +        expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("inside-ok\n");
    +      },
    +    });
    +  });
    +
    +  it("rejects symlinked SPA fallback index.html outside control-ui root", async () => {
    +    await withControlUiRoot({
    +      fn: async (tmp) => {
    +        const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-outside-"));
    +        try {
    +          const outsideIndex = path.join(outsideDir, "index.html");
    +          await fs.writeFile(outsideIndex, "<html>outside</html>\n");
    +          await fs.rm(path.join(tmp, "index.html"));
    +          await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
    +
    +          const { res, end } = makeMockHttpResponse();
    +          const handled = handleControlUiHttpRequest(
    +            { url: "/app/route", method: "GET" } as IncomingMessage,
    +            res,
    +            {
    +              root: { kind: "resolved", path: tmp },
    +            },
    +          );
    +
    +          expect(handled).toBe(true);
    +          expect(res.statusCode).toBe(404);
    +          expect(end).toHaveBeenCalledWith("Not Found");
    +        } finally {
    +          await fs.rm(outsideDir, { recursive: true, force: true });
    +        }
    +      },
    +    });
    +  });
     });
    
  • src/gateway/control-ui.ts+72 8 modified
    @@ -188,10 +188,72 @@ function serveFile(res: ServerResponse, filePath: string) {
       res.end(fs.readFileSync(filePath));
     }
     
    -function serveIndexHtml(res: ServerResponse, indexPath: string) {
    +function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) {
    +  const ext = path.extname(filePath).toLowerCase();
    +  res.setHeader("Content-Type", contentTypeForExt(ext));
    +  res.setHeader("Cache-Control", "no-cache");
    +  res.end(body);
    +}
    +
    +function serveResolvedIndexHtml(res: ServerResponse, body: string) {
       res.setHeader("Content-Type", "text/html; charset=utf-8");
       res.setHeader("Cache-Control", "no-cache");
    -  res.end(fs.readFileSync(indexPath, "utf8"));
    +  res.end(body);
    +}
    +
    +function isContainedPath(baseDir: string, targetPath: string): boolean {
    +  const relative = path.relative(baseDir, targetPath);
    +  return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
    +}
    +
    +function isExpectedSafePathError(error: unknown): boolean {
    +  const code =
    +    typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
    +  return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
    +}
    +
    +function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
    +  return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
    +}
    +
    +function resolveSafeControlUiFile(
    +  root: string,
    +  filePath: string,
    +): { path: string; body: Buffer } | null {
    +  let fd: number | null = null;
    +  try {
    +    const rootReal = fs.realpathSync(root);
    +    const fileReal = fs.realpathSync(filePath);
    +    if (!isContainedPath(rootReal, fileReal)) {
    +      return null;
    +    }
    +
    +    const preOpenStat = fs.lstatSync(fileReal);
    +    if (!preOpenStat.isFile()) {
    +      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);
    +    // Compare inode identity so swaps between validation and open are rejected.
    +    if (!openedStat.isFile() || !areSameFileIdentity(preOpenStat, openedStat)) {
    +      return null;
    +    }
    +
    +    return { path: fileReal, body: fs.readFileSync(fd) };
    +  } catch (error) {
    +    if (isExpectedSafePathError(error)) {
    +      return null;
    +    }
    +    throw error;
    +  } finally {
    +    if (fd !== null) {
    +      fs.closeSync(fd);
    +    }
    +  }
     }
     
     function isSafeRelativePath(relPath: string) {
    @@ -340,12 +402,13 @@ export function handleControlUiHttpRequest(
         return true;
       }
     
    -  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
    -    if (path.basename(filePath) === "index.html") {
    -      serveIndexHtml(res, filePath);
    +  const safeFile = resolveSafeControlUiFile(root, filePath);
    +  if (safeFile) {
    +    if (path.basename(safeFile.path) === "index.html") {
    +      serveResolvedIndexHtml(res, safeFile.body.toString("utf8"));
           return true;
         }
    -    serveFile(res, filePath);
    +    serveResolvedFile(res, safeFile.path, safeFile.body);
         return true;
       }
     
    @@ -361,8 +424,9 @@ 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);
    +  const safeIndex = resolveSafeControlUiFile(root, indexPath);
    +  if (safeIndex) {
    +    serveResolvedIndexHtml(res, safeIndex.body.toString("utf8"));
         return 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

4

News mentions

0

No linked articles in our index yet.