VYPR
Medium severity4.3NVD Advisory· Published May 6, 2026· Updated May 7, 2026

CVE-2026-44111

CVE-2026-44111

Description

OpenClaw before 2026.4.15 contains an arbitrary file read vulnerability in the QMD backend memory_get function that allows callers to read any Markdown files within the workspace root. Attackers with access to the memory tool can bypass path restrictions by providing arbitrary workspace Markdown paths to read files outside canonical memory locations or indexed QMD result sets.

Affected products

2
  • OpenClaw/Openclawreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*range: <2026.4.15

Patches

1
37d5971db364

Align QMD memory reads with canonical memory paths (#66026)

https://github.com/openclaw/openclawAgustin RiveraApr 14, 2026via nvd-ref
4 files changed · +112 21
  • CHANGELOG.md+1 0 modified
    @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
     ### Fixes
     
     - Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.
    +- Memory-core/QMD `memory_get`: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (`MEMORY.md`, `memory.md`, `DREAMS.md`, `dreams.md`, `memory/**`) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses `read` tool-policy denials. (#66026) Thanks @eleqtrizit.
     
     ## 2026.4.14
     
    
  • extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts+23 5 modified
    @@ -11,17 +11,17 @@ const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
       logInfoMock: vi.fn(),
     }));
     
    -type MockChild = EventEmitter & {
    +interface MockChild extends EventEmitter {
       stdout: EventEmitter;
       stderr: EventEmitter;
       kill: (signal?: NodeJS.Signals) => void;
       closeWith: (code?: number | null) => void;
    -};
    +}
     
     function createMockChild(params?: { autoClose?: boolean }): MockChild {
       const stdout = new EventEmitter();
       const stderr = new EventEmitter();
    -  const child = new EventEmitter() as MockChild;
    +  const child = new EventEmitter() as unknown as MockChild;
       child.stdout = stdout;
       child.stderr = stderr;
       child.closeWith = (code = 0) => {
    @@ -123,14 +123,32 @@ describe("QmdMemoryManager slugified path resolution", () => {
       }) {
         const inner = params.manager as unknown as {
           db: {
    -        prepare: (query: string) => { all: (...args: unknown[]) => unknown };
    +        prepare: (query: string) => {
    +          get: (...args: unknown[]) => unknown;
    +          all: (...args: unknown[]) => unknown;
    +        };
             close: () => void;
           };
         };
         inner.db = {
           prepare: (query: string) => ({
    +        get: (...args: unknown[]) => {
    +          if (query.includes("collection = ? AND active = 1 AND path = ?")) {
    +            expect(args[0]).toBe(params.collection);
    +            const requestedPath = args[1];
    +            expect(typeof requestedPath).toBe("string");
    +            const exactCandidates = new Set([
    +              ...(params.exactPaths ?? []),
    +              ...(params.actualPath ? [params.actualPath] : []),
    +            ]);
    +            return typeof requestedPath === "string" && exactCandidates.has(requestedPath)
    +              ? { path: requestedPath }
    +              : undefined;
    +          }
    +          throw new Error(`unexpected sqlite query: ${query}`);
    +        },
             all: (...args: unknown[]) => {
    -          if (query.includes("collection = ? AND path = ?")) {
    +          if (query.includes("collection = ? AND path = ? AND active = 1")) {
                 expect(args).toEqual([params.collection, params.normalizedPath]);
                 return (params.exactPaths ?? []).map((pathValue) => ({ path: pathValue }));
               }
    
  • extensions/memory-core/src/memory/qmd-manager.test.ts+24 6 modified
    @@ -3559,14 +3559,31 @@ describe("QmdMemoryManager", () => {
         await manager.close();
       });
     
    -  it("reads only requested line ranges without loading the whole file", async () => {
    +  it("rejects non-memory workspace markdown reads", async () => {
    +    await fs.writeFile(path.join(workspaceDir, "window.md"), "secret", "utf-8");
    +    await fs.mkdir(path.join(workspaceDir, ".memory"), { recursive: true });
    +    await fs.writeFile(path.join(workspaceDir, ".memory", "hidden.md"), "secret", "utf-8");
    +
    +    const { manager } = await createManager();
    +
    +    await expect(manager.readFile({ relPath: "window.md" })).rejects.toThrow("path required");
    +    await expect(manager.readFile({ relPath: ".memory/hidden.md" })).rejects.toThrow(
    +      "path required",
    +    );
    +
    +    await manager.close();
    +  });
    +
    +  it("reads only requested line ranges from canonical memory files without loading the whole file", async () => {
         const readFileSpy = vi.spyOn(fs, "readFile");
         const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n");
    -    await fs.writeFile(path.join(workspaceDir, "window.md"), text, "utf-8");
    +    const relPath = path.join("memory", "window.md");
    +    await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
    +    await fs.writeFile(path.join(workspaceDir, relPath), text, "utf-8");
     
         const { manager } = await createManager();
     
    -    const result = await manager.readFile({ relPath: "window.md", from: 10, lines: 3 });
    +    const result = await manager.readFile({ relPath, from: 10, lines: 3 });
         expect(result.text).toBe("line-10\nline-11\nline-12");
         expect(readFileSpy).not.toHaveBeenCalled();
     
    @@ -3575,15 +3592,16 @@ describe("QmdMemoryManager", () => {
       });
     
       it("returns empty text when qmd files are missing before or during read", async () => {
    -    const relPath = "qmd-window.md";
    +    const relPath = path.join("memory", "qmd-window.md");
         const absPath = path.join(workspaceDir, relPath);
    +    await fs.mkdir(path.dirname(absPath), { recursive: true });
         await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8");
     
         const cases = [
           {
             name: "missing before read",
    -        request: { relPath: "ghost.md" },
    -        expectedPath: "ghost.md",
    +        request: { relPath: path.join("memory", "ghost.md") },
    +        expectedPath: path.join("memory", "ghost.md"),
           },
           {
             name: "disappears before partial read",
    
  • extensions/memory-core/src/memory/qmd-manager.ts+64 10 modified
    @@ -15,7 +15,6 @@ import {
       resolveStateDir,
       writeFileWithinRoot,
       type OpenClawConfig,
    -  type ResolvedMemorySearchSyncConfig,
     } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
     import {
       buildSessionEntry,
    @@ -82,6 +81,22 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
       "__pycache__",
     ]);
     
    +function isDefaultMemoryPath(relPath: string): boolean {
    +  const normalized = relPath.trim().replace(/^\.\//, "").replace(/\\/g, "/");
    +  if (!normalized) {
    +    return false;
    +  }
    +  if (
    +    normalized === "MEMORY.md" ||
    +    normalized === "memory.md" ||
    +    normalized === "DREAMS.md" ||
    +    normalized === "dreams.md"
    +  ) {
    +    return true;
    +  }
    +  return normalized.startsWith("memory/");
    +}
    +
     function buildQmdProcessPath(rawPath: string | undefined): string {
       const nodeBinDir = path.dirname(process.execPath);
       const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? [];
    @@ -256,7 +271,7 @@ export class QmdMemoryManager implements MemorySearchManager {
       private readonly xdgCacheHome: string;
       private readonly indexPath: string;
       private readonly env: NodeJS.ProcessEnv;
    -  private readonly syncSettings: ResolvedMemorySearchSyncConfig | null;
    +  private readonly syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
       private readonly managedCollectionNames: string[];
       private readonly collectionRoots = new Map<string, CollectionRoot>();
       private readonly sources = new Set<MemorySource>();
    @@ -1189,14 +1204,7 @@ export class QmdMemoryManager implements MemorySearchManager {
         if (full.missing) {
           return { text: "", path: relPath };
         }
    -    if (!params.from && !params.lines) {
    -      return { text: full.text, path: relPath };
    -    }
    -    const lines = full.text.split("\n");
    -    const start = Math.max(1, params.from ?? 1);
    -    const count = Math.max(1, params.lines ?? lines.length);
    -    const slice = lines.slice(start - 1, start - 1 + count);
    -    return { text: slice.join("\n"), path: relPath };
    +    return { text: full.text, path: relPath };
       }
     
       status(): MemoryProviderStatus {
    @@ -2518,9 +2526,55 @@ export class QmdMemoryManager implements MemorySearchManager {
         if (!this.isWithinWorkspace(absPath)) {
           throw new Error("path escapes workspace");
         }
    +    const workspaceRel = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
    +    if (!isDefaultMemoryPath(workspaceRel) && !this.isIndexedWorkspaceReadPath(absPath)) {
    +      throw new Error("path required");
    +    }
         return absPath;
       }
     
    +  private isIndexedWorkspaceReadPath(absPath: string): boolean {
    +    const normalizedAbsPath = path.normalize(absPath);
    +    for (const [collection, root] of this.collectionRoots.entries()) {
    +      if (!this.isWithinRoot(root.path, normalizedAbsPath)) {
    +        continue;
    +      }
    +      const collectionRelativePath = path
    +        .relative(root.path, normalizedAbsPath)
    +        .replace(/\\/g, "/");
    +      if (!collectionRelativePath || collectionRelativePath.startsWith("..")) {
    +        continue;
    +      }
    +      try {
    +        const exactRow = this.ensureDb()
    +          .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1 AND path = ?")
    +          .get(collection, collectionRelativePath) as { path: string } | undefined;
    +        if (
    +          exactRow &&
    +          path.normalize(path.resolve(root.path, exactRow.path)) === normalizedAbsPath
    +        ) {
    +          return true;
    +        }
    +        const rows = this.ensureDb()
    +          .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
    +          .all(collection) as Array<{ path: string }>;
    +        const match = rows.find((row) =>
    +          this.matchesPreferredFileHint(row.path, collectionRelativePath),
    +        );
    +        if (match && path.normalize(path.resolve(root.path, match.path)) === normalizedAbsPath) {
    +          return true;
    +        }
    +      } catch (err) {
    +        if (this.isSqliteBusyError(err)) {
    +          log.debug(`qmd index is busy while checking read path: ${String(err)}`);
    +          throw this.createQmdBusyError(err);
    +        }
    +        log.debug(`qmd indexed read-path lookup skipped: ${String(err)}`);
    +      }
    +    }
    +    return false;
    +  }
    +
       private isWithinWorkspace(absPath: string): boolean {
         const normalizedWorkspace = this.workspaceDir.endsWith(path.sep)
           ? this.workspaceDir
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

3

News mentions

0

No linked articles in our index yet.