diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d6de7fbd..4e9bd5fd9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin. - Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts index bea0548648..dd865d4c68 100644 --- a/src/canvas-host/a2ui.ts +++ b/src/canvas-host/a2ui.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { SafeOpenError, openFileWithinRoot, type SafeOpenResult } from "../infra/fs-safe.js"; import { detectMime } from "../media/mime.js"; export const A2UI_PATH = "/__openclaw__/a2ui"; @@ -62,41 +63,42 @@ function normalizeUrlPath(rawPath: string): string { return normalized.startsWith("/") ? normalized : `/${normalized}`; } -async function resolveA2uiFilePath(rootReal: string, urlPath: string) { +async function resolveA2uiFile(rootReal: string, urlPath: string): Promise { const normalized = normalizeUrlPath(urlPath); const rel = normalized.replace(/^\/+/, ""); if (rel.split("/").some((p) => p === "..")) { return null; } - let candidate = path.join(rootReal, rel); + const tryOpen = async (relative: string) => { + try { + return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative }); + } catch (err) { + if (err instanceof SafeOpenError) { + return null; + } + throw err; + } + }; + if (normalized.endsWith("/")) { - candidate = path.join(candidate, "index.html"); + return await tryOpen(path.posix.join(rel, "index.html")); } + const candidate = path.join(rootReal, rel); try { - const st = await fs.stat(candidate); + const st = await fs.lstat(candidate); + if (st.isSymbolicLink()) { + return null; + } if (st.isDirectory()) { - candidate = path.join(candidate, "index.html"); + return await tryOpen(path.posix.join(rel, "index.html")); } } catch { // ignore } - const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`; - try { - const lstat = await fs.lstat(candidate); - if (lstat.isSymbolicLink()) { - return null; - } - const real = await fs.realpath(candidate); - if (!real.startsWith(rootPrefix)) { - return null; - } - return real; - } catch { - return null; - } + return await tryOpen(rel); } export function injectCanvasLiveReload(html: string): string { @@ -190,29 +192,39 @@ export async function handleA2uiHttpRequest( } const rel = url.pathname.slice(basePath.length); - const filePath = await resolveA2uiFilePath(a2uiRootReal, rel || "/"); - if (!filePath) { + const result = await resolveA2uiFile(a2uiRootReal, rel || "/"); + if (!result) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("not found"); return true; } - const lower = filePath.toLowerCase(); - const mime = - lower.endsWith(".html") || lower.endsWith(".htm") - ? "text/html" - : ((await detectMime({ filePath })) ?? "application/octet-stream"); - res.setHeader("Cache-Control", "no-store"); + try { + const lower = result.realPath.toLowerCase(); + const mime = + lower.endsWith(".html") || lower.endsWith(".htm") + ? "text/html" + : ((await detectMime({ filePath: result.realPath })) ?? "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); - if (mime === "text/html") { - const html = await fs.readFile(filePath, "utf8"); - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(injectCanvasLiveReload(html)); + if (req.method === "HEAD") { + res.setHeader("Content-Type", mime === "text/html" ? "text/html; charset=utf-8" : mime); + res.end(); + return true; + } + + if (mime === "text/html") { + const buf = await result.handle.readFile({ encoding: "utf8" }); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(injectCanvasLiveReload(buf)); + return true; + } + + res.setHeader("Content-Type", mime); + res.end(await result.handle.readFile()); return true; + } finally { + await result.handle.close().catch(() => {}); } - - res.setHeader("Content-Type", mime); - res.end(await fs.readFile(filePath)); - return true; } diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e59651aa12..b768aa02b4 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { defaultRuntime } from "../runtime.js"; -import { CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; +import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; import { createCanvasHostHandler, startCanvasHost } from "./server.js"; describe("canvas host", () => { @@ -246,4 +246,81 @@ describe("canvas host", () => { await fs.rm(dir, { recursive: true, force: true }); } }); + + it("rejects traversal-style A2UI asset requests", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); + const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); + const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); + let createdBundle = false; + + try { + await fs.stat(bundlePath); + } catch { + await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8"); + createdBundle = true; + } + + const server = await startCanvasHost({ + runtime: defaultRuntime, + rootDir: dir, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + }); + + try { + const res = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`); + expect(res.status).toBe(404); + expect(await res.text()).toBe("not found"); + } finally { + await server.close(); + if (createdBundle) { + await fs.rm(bundlePath, { force: true }); + } + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("rejects A2UI symlink escapes", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); + const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); + const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); + const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`; + const linkPath = path.join(a2uiRoot, linkName); + let createdBundle = false; + let createdLink = false; + + try { + await fs.stat(bundlePath); + } catch { + await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8"); + createdBundle = true; + } + + await fs.symlink(path.join(process.cwd(), "package.json"), linkPath); + createdLink = true; + + const server = await startCanvasHost({ + runtime: defaultRuntime, + rootDir: dir, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + }); + + try { + const res = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`); + expect(res.status).toBe(404); + expect(await res.text()).toBe("not found"); + } finally { + await server.close(); + if (createdLink) { + await fs.rm(linkPath, { force: true }); + } + if (createdBundle) { + await fs.rm(bundlePath, { force: true }); + } + await fs.rm(dir, { recursive: true, force: true }); + } + }); });