security: use openFileWithinRoot for A2UI file serving (#10525)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 64547d6f904a7660c3f0f3565252d9098b6aa85a Co-authored-by: abdelsfane <32418586+abdelsfane@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
This commit is contained in:
parent
30b6eccae5
commit
7467fcc529
@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { SafeOpenError, openFileWithinRoot, type SafeOpenResult } from "../infra/fs-safe.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
|
|
||||||
export const A2UI_PATH = "/__openclaw__/a2ui";
|
export const A2UI_PATH = "/__openclaw__/a2ui";
|
||||||
@ -62,41 +63,42 @@ function normalizeUrlPath(rawPath: string): string {
|
|||||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveA2uiFilePath(rootReal: string, urlPath: string) {
|
async function resolveA2uiFile(rootReal: string, urlPath: string): Promise<SafeOpenResult | null> {
|
||||||
const normalized = normalizeUrlPath(urlPath);
|
const normalized = normalizeUrlPath(urlPath);
|
||||||
const rel = normalized.replace(/^\/+/, "");
|
const rel = normalized.replace(/^\/+/, "");
|
||||||
if (rel.split("/").some((p) => p === "..")) {
|
if (rel.split("/").some((p) => p === "..")) {
|
||||||
return null;
|
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("/")) {
|
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 {
|
try {
|
||||||
const st = await fs.stat(candidate);
|
const st = await fs.lstat(candidate);
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (st.isDirectory()) {
|
if (st.isDirectory()) {
|
||||||
candidate = path.join(candidate, "index.html");
|
return await tryOpen(path.posix.join(rel, "index.html"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
|
return await tryOpen(rel);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function injectCanvasLiveReload(html: string): string {
|
export function injectCanvasLiveReload(html: string): string {
|
||||||
@ -190,29 +192,39 @@ export async function handleA2uiHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rel = url.pathname.slice(basePath.length);
|
const rel = url.pathname.slice(basePath.length);
|
||||||
const filePath = await resolveA2uiFilePath(a2uiRootReal, rel || "/");
|
const result = await resolveA2uiFile(a2uiRootReal, rel || "/");
|
||||||
if (!filePath) {
|
if (!result) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
res.end("not found");
|
res.end("not found");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lower = filePath.toLowerCase();
|
try {
|
||||||
const mime =
|
const lower = result.realPath.toLowerCase();
|
||||||
lower.endsWith(".html") || lower.endsWith(".htm")
|
const mime =
|
||||||
? "text/html"
|
lower.endsWith(".html") || lower.endsWith(".htm")
|
||||||
: ((await detectMime({ filePath })) ?? "application/octet-stream");
|
? "text/html"
|
||||||
res.setHeader("Cache-Control", "no-store");
|
: ((await detectMime({ filePath: result.realPath })) ?? "application/octet-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
|
||||||
if (mime === "text/html") {
|
if (req.method === "HEAD") {
|
||||||
const html = await fs.readFile(filePath, "utf8");
|
res.setHeader("Content-Type", mime === "text/html" ? "text/html; charset=utf-8" : mime);
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.end();
|
||||||
res.end(injectCanvasLiveReload(html));
|
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;
|
return true;
|
||||||
|
} finally {
|
||||||
|
await result.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader("Content-Type", mime);
|
|
||||||
res.end(await fs.readFile(filePath));
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
import { defaultRuntime } from "../runtime.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";
|
import { createCanvasHostHandler, startCanvasHost } from "./server.js";
|
||||||
|
|
||||||
describe("canvas host", () => {
|
describe("canvas host", () => {
|
||||||
@ -246,4 +246,81 @@ describe("canvas host", () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user