OpenClaw < 2026.2.24 - Arbitrary File Read via Improper Temporary Path Validation in Sandbox
Description
OpenClaw versions prior to 2026.2.24 contain an improper path validation vulnerability in sandbox media handling that allows absolute paths under the host temporary directory outside the active sandbox root. Attackers can exploit this by providing malicious media references to read and exfiltrate arbitrary files from the host temporary directory through attachment delivery mechanisms.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.24 | 2026.2.24 |
Affected products
1Patches
3def993dbd843refactor(tmp): harden temp boundary guardrails
6 files changed · +84 −27
scripts/check-no-random-messaging-tmp.mjs+4 −3 modified@@ -47,7 +47,8 @@ async function collectTypeScriptFiles(dir) { return out; } -function collectNodeOsImports(sourceFile) { +function collectOsTmpdirImports(sourceFile) { + const osModuleSpecifiers = new Set(["node:os", "os"]); const osNamespaceOrDefault = new Set(); const namedTmpdir = new Set(); for (const statement of sourceFile.statements) { @@ -57,7 +58,7 @@ function collectNodeOsImports(sourceFile) { if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { continue; } - if (statement.moduleSpecifier.text !== "node:os") { + if (!osModuleSpecifiers.has(statement.moduleSpecifier.text)) { continue; } const clause = statement.importClause; @@ -101,7 +102,7 @@ function unwrapExpression(expression) { export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); - const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile); + const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); const lines = []; const visit = (node) => {
SECURITY.md+4 −0 modified@@ -171,6 +171,10 @@ Security boundary notes: - Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. - Arbitrary host tmp paths are not treated as trusted media roots. - Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. +- Enforcement reference points: + - temp root resolver: `src/infra/tmp-openclaw-dir.ts` + - SDK temp helpers: `src/plugin-sdk/temp-path.ts` + - messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs` ## Operational Guidance
src/infra/tmp-openclaw-dir.ts+34 −21 modified@@ -66,35 +66,48 @@ export function resolvePreferredOpenClawTmpDir( return path.join(base, suffix); }; - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); - if (!isSecureDirForUser(preferred)) { - return fallback(); + const isTrustedPreferredDir = (st: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode?: number; + uid?: number; + }): boolean => { + return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st); + }; + + const resolvePreferredState = ( + requireWritableAccess: boolean, + ): "available" | "missing" | "invalid" => { + try { + const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); + if (!isTrustedPreferredDir(preferred)) { + return "invalid"; + } + if (requireWritableAccess) { + accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); + } + return "available"; + } catch (err) { + if (isNodeErrorWithCode(err, "ENOENT")) { + return "missing"; + } + return "invalid"; } + }; + + const existingPreferredState = resolvePreferredState(true); + if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; - } catch (err) { - if (!isNodeErrorWithCode(err, "ENOENT")) { - return fallback(); - } + } + if (existingPreferredState === "invalid") { + return fallback(); } try { accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - if (!isSecureDirForUser(preferred)) { - return fallback(); - } - } catch { + if (resolvePreferredState(true) !== "available") { return fallback(); } return POSIX_OPENCLAW_TMP_DIR;
src/media/local-roots.ts+18 −2 modified@@ -4,9 +4,25 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -function buildMediaLocalRoots(stateDir: string): string[] { +type BuildMediaLocalRootsOptions = { + preferredTmpDir?: string; +}; + +let cachedPreferredTmpDir: string | undefined; + +function resolveCachedPreferredTmpDir(): string { + if (!cachedPreferredTmpDir) { + cachedPreferredTmpDir = resolvePreferredOpenClawTmpDir(); + } + return cachedPreferredTmpDir; +} + +function buildMediaLocalRoots( + stateDir: string, + options: BuildMediaLocalRootsOptions = {}, +): string[] { const resolvedStateDir = path.resolve(stateDir); - const preferredTmpDir = resolvePreferredOpenClawTmpDir(); + const preferredTmpDir = options.preferredTmpDir ?? resolveCachedPreferredTmpDir(); return [ preferredTmpDir, path.join(resolvedStateDir, "media"),
src/plugin-sdk/temp-path.ts+16 −1 modified@@ -31,6 +31,15 @@ function resolveTempRoot(tmpDir?: string): string { return tmpDir ?? resolvePreferredOpenClawTmpDir(); } +function isNodeErrorWithCode(err: unknown, code: string): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: string }).code === code + ); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -64,6 +73,12 @@ export async function withTempDownloadPath<T>( try { return await fn(tmpPath); } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); + try { + await rm(dir, { recursive: true, force: true }); + } catch (err) { + if (!isNodeErrorWithCode(err, "ENOENT")) { + console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`); + } + } } }
test/scripts/check-no-random-messaging-tmp.test.ts+8 −0 modified@@ -18,6 +18,14 @@ describe("check-no-random-messaging-tmp", () => { expect(findMessagingTmpdirCallLines(source)).toEqual([3]); }); + it("finds tmpdir calls imported from os", () => { + const source = ` + import os from "os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + it("ignores mentions in comments and strings", () => { const source = ` // os.tmpdir()
79a7b3d22ef9test(line): align tmp-root expectation after sandbox hardening
2 files changed · +3 −3
CHANGELOG.md+1 −1 modified@@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. -- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. This ships in the next npm release. Thanks @tdjackey for reporting. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. - Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting.
src/line/download.test.ts+2 −2 modified@@ -1,7 +1,7 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const getMessageContentMock = vi.hoisted(() => vi.fn()); @@ -54,7 +54,7 @@ describe("downloadLineMedia", () => { expect(writtenPath).not.toContain(messageId); expect(writtenPath).not.toContain(".."); - const tmpRoot = path.resolve(os.tmpdir()); + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const rel = path.relative(tmpRoot, path.resolve(writtenPath)); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); });
d3da67c7a9b4fix(security): lock sandbox tmp media paths to openclaw roots
13 files changed · +364 −31
CHANGELOG.md+1 −0 modified@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting.
extensions/llm-task/src/llm-task-tool.ts+4 −2 modified@@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; // NOTE: This extension is intended to be bundled with OpenClaw. // When running from source (tests/dev), OpenClaw internals live under src/. // When running from a built install, internals live under dist/ (no src/ tree). @@ -180,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { let tmpDir: string | null = null; try { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + tmpDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"), + ); const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json");
package.json+2 −1 modified@@ -54,7 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -93,6 +93,7 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh",
scripts/check-no-random-messaging-tmp.mjs+173 −0 added@@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "infra", "outbound"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "src", "media-understanding"), + path.join(repoRoot, "extensions"), +]; +const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function collectNodeOsImports(sourceFile) { + const osNamespaceOrDefault = new Set(); + const namedTmpdir = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + if (statement.moduleSpecifier.text !== "node:os") { + continue; + } + const clause = statement.importClause; + if (clause.name) { + osNamespaceOrDefault.add(clause.name.text); + } + if (!clause.namedBindings) { + continue; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + osNamespaceOrDefault.add(clause.namedBindings.name.text); + continue; + } + for (const element of clause.namedBindings.elements) { + if ((element.propertyName?.text ?? element.name.text) === "tmpdir") { + namedTmpdir.add(element.name.text); + } + } + } + return { osNamespaceOrDefault, namedTmpdir }; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "tmpdir" && + ts.isIdentifier(callee.expression) && + osNamespaceOrDefault.has(callee.expression.text) + ) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + ).flat(); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const line of findMessagingTmpdirCallLines(content, filePath)) { + violations.push(`${path.relative(repoRoot, filePath)}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +}
src/agents/sandbox-paths.test.ts+27 −14 modified@@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolveSandboxedMediaSource } from "./sandbox-paths.js"; async function withSandboxRoot<T>(run: (sandboxDir: string) => Promise<T>) { @@ -24,22 +25,24 @@ function isPathInside(root: string, target: string): boolean { } describe("resolveSandboxedMediaSource", () => { + const openClawTmpDir = resolvePreferredOpenClawTmpDir(); + // Group 1: /tmp paths (the bug fix) it.each([ { - name: "absolute paths under os.tmpdir()", - media: path.join(os.tmpdir(), "image.png"), - expected: path.join(os.tmpdir(), "image.png"), + name: "absolute paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "image.png"), + expected: path.join(openClawTmpDir, "image.png"), }, { - name: "file:// URLs pointing to os.tmpdir()", - media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href, - expected: path.join(os.tmpdir(), "photo.png"), + name: "file:// URLs pointing to preferred OpenClaw tmp root", + media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href, + expected: path.join(openClawTmpDir, "photo.png"), }, { - name: "nested paths under os.tmpdir()", - media: path.join(os.tmpdir(), "subdir", "deep", "file.png"), - expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"), + name: "nested paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "subdir", "deep", "file.png"), + expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"), }, ])("allows $name", async ({ media, expected }) => { await withSandboxRoot(async (sandboxDir) => { @@ -96,7 +99,12 @@ describe("resolveSandboxedMediaSource", () => { }, { name: "path traversal through tmpdir", - media: path.join(os.tmpdir(), "..", "etc", "passwd"), + media: path.join(openClawTmpDir, "..", "etc", "passwd"), + expected: /sandbox/i, + }, + { + name: "absolute paths under host tmp outside openclaw tmp root", + media: path.join(os.tmpdir(), "outside-openclaw", "passwd"), expected: /sandbox/i, }, { @@ -120,20 +128,25 @@ describe("resolveSandboxedMediaSource", () => { }); }); - it("rejects symlinked tmpdir paths escaping tmpdir", async () => { + it("rejects symlinked OpenClaw tmp paths escaping tmp root", async () => { if (process.platform === "win32") { return; } const outsideTmpTarget = path.resolve(process.cwd(), "package.json"); - if (isPathInside(os.tmpdir(), outsideTmpTarget)) { + if (isPathInside(openClawTmpDir, outsideTmpTarget)) { return; } await withSandboxRoot(async (sandboxDir) => { await fs.access(outsideTmpTarget); - const symlinkPath = path.join(sandboxDir, "tmp-link-escape"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + const symlinkPath = path.join(openClawTmpDir, `tmp-link-escape-${process.pid}`); await fs.symlink(outsideTmpTarget, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + try { + await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.unlink(symlinkPath).catch(() => {}); + } }); });
src/agents/sandbox-paths.ts+4 −3 modified@@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const HTTP_URL_RE = /^https?:\/\//i; @@ -181,11 +182,11 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot)); - const tmpDir = path.resolve(os.tmpdir()); - if (!isPathInside(tmpDir, resolved)) { + const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir()); + if (!isPathInside(openClawTmpDir, resolved)) { return undefined; } - await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); + await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); return resolved; }
src/infra/outbound/deliver.test.ts+81 −0 modified@@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c import { withEnvAsync } from "../../test-utils/env.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), @@ -202,6 +203,86 @@ describe("deliverOutboundPayloads", () => { ); }); + it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverOutboundPayloads({ + cfg: telegramChunkConfig, + channel: "telegram", + to: "123", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => { + const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); + + await deliverOutboundPayloads({ + cfg: { channels: { signal: {} } }, + channel: "signal", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => { + const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "imessage", + to: "imessage:+15551234567", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "imessage:+15551234567", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } };
src/infra/outbound/message-action-runner.test.ts+20 −2 modified@@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; vi.mock("../../web/media.js", async () => { @@ -622,10 +623,12 @@ describe("runMessageAction sandboxed media validation", () => { }); }); - it("allows media paths under os.tmpdir()", async () => { + it("allows media paths under preferred OpenClaw tmp root", async () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + await fs.mkdir(tmpRoot, { recursive: true }); const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); try { - const tmpFile = path.join(os.tmpdir(), "test-media-image.png"); + const tmpFile = path.join(tmpRoot, "test-media-image.png"); const result = await runMessageAction({ cfg: slackConfig, action: "send", @@ -644,6 +647,21 @@ describe("runMessageAction sandboxed media validation", () => { throw new Error("expected send result"); } expect(result.sendResult?.mediaUrl).toBe(tmpFile); + const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: hostTmpOutsideOpenClaw, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }), + ).rejects.toThrow(/sandbox/i); } finally { await fs.rm(sandboxDir, { recursive: true, force: true }); }
src/media-understanding/runner.entries.ts+4 −2 modified@@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { collectProviderApiKeysForExecution, @@ -14,6 +13,7 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; import { MediaAttachmentCache } from "./attachments.js"; import { @@ -566,7 +566,9 @@ export async function runCliEntry(params: { maxBytes, timeoutMs, }); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cli-")); + const outputDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-cli-"), + ); const mediaPath = pathResult.path; const outputBase = path.join(outputDir, path.parse(mediaPath).name);
src/plugin-sdk/index.ts+1 −0 modified@@ -200,6 +200,7 @@ export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { runPluginCommandWithTimeout, type PluginCommandRunOptions,
src/plugin-sdk/temp-path.test.ts+4 −4 modified@@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; describe("buildRandomTempFilePath", () => { @@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => { }); it("sanitizes prefix and extension to avoid path traversal segments", () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const result = buildRandomTempFilePath({ prefix: "../../line/../media", extension: "/../.jpg", now: 123, uuid: "abc", }); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(result); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); @@ -45,11 +45,12 @@ describe("withTempDownloadPath", () => { }, ); - expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-")); + expect(capturedPath).toContain(path.join(resolvePreferredOpenClawTmpDir(), "line-media-")); await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("sanitizes prefix and fileName", async () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); let capturedPath = ""; await withTempDownloadPath( { @@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => { }, ); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(capturedPath); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
src/plugin-sdk/temp-path.ts+7 −3 modified@@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; function sanitizePrefix(prefix: string): string { const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); @@ -27,6 +27,10 @@ function sanitizeFileName(fileName: string): string { return normalized || "download.bin"; } +function resolveTempRoot(tmpDir?: string): string { + return tmpDir ?? resolvePreferredOpenClawTmpDir(); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -42,7 +46,7 @@ export function buildRandomTempFilePath(params: { ? Math.trunc(nowCandidate) : Date.now(); const uuid = params.uuid?.trim() || crypto.randomUUID(); - return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`); + return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`); } export async function withTempDownloadPath<T>( @@ -53,7 +57,7 @@ export async function withTempDownloadPath<T>( }, fn: (tmpPath: string) => Promise<T>, ): Promise<T> { - const tempRoot = params.tmpDir ?? os.tmpdir(); + const tempRoot = resolveTempRoot(params.tmpDir); const prefix = `${sanitizePrefix(params.prefix)}-`; const dir = await mkdtemp(path.join(tempRoot, prefix)); const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
test/scripts/check-no-random-messaging-tmp.test.ts+36 −0 added@@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs"; + +describe("check-no-random-messaging-tmp", () => { + it("finds os.tmpdir calls imported from node:os", () => { + const source = ` + import os from "node:os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("finds tmpdir named import calls from node:os", () => { + const source = ` + import { tmpdir } from "node:os"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("ignores mentions in comments and strings", () => { + const source = ` + // os.tmpdir() + const text = "tmpdir()"; + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); + + it("ignores tmpdir symbols that are not imported from node:os", () => { + const source = ` + const tmpdir = () => "/tmp"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); +});
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
7- github.com/openclaw/openclaw/commit/79a7b3d22ef92e36a4031093d80a0acb0d82f351ghsapatchWEB
- github.com/openclaw/openclaw/commit/d3da67c7a9b463edc1a9b1c1f7af107a34ca32f5ghsapatchWEB
- github.com/openclaw/openclaw/commit/def993dbd843ff28f2b3bad5cc24603874ba9f1eghsapatchWEB
- github.com/advisories/GHSA-33hm-cq8r-wc49ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-33hm-cq8r-wc49ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32026ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-read-via-improper-temporary-path-validation-in-sandboxghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.