fix(security): harden workspace bootstrap boundary reads

This commit is contained in:
Peter Steinberger
2026-03-02 17:07:26 +00:00
parent 67b2dde7c5
commit 07b16d5ad0
8 changed files with 190 additions and 7 deletions

View File

@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths. - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale. - Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting. - Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.

View File

@@ -38,6 +38,8 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the `openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
workspace and seed the bootstrap files if they are missing. workspace and seed the bootstrap files if they are missing.
Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
aliases that resolve outside the source workspace are ignored.
If you already manage the workspace files yourself, you can disable bootstrap If you already manage the workspace files yourself, you can disable bootstrap
file creation: file creation:

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -13,6 +16,7 @@ const {
formatToolFailuresSection, formatToolFailuresSection,
computeAdaptiveChunkRatio, computeAdaptiveChunkRatio,
isOversizedForSummary, isOversizedForSummary,
readWorkspaceContextForSummary,
BASE_CHUNK_RATIO, BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO, MIN_CHUNK_RATIO,
SAFETY_MARGIN, SAFETY_MARGIN,
@@ -484,3 +488,41 @@ describe("compaction-safeguard double-compaction guard", () => {
expect(getApiKeyMock).toHaveBeenCalled(); expect(getApiKeyMock).toHaveBeenCalled();
}); });
}); });
describe("readWorkspaceContextForSummary", () => {
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a symlink escape",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
},
);
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a hardlink alias",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.linkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
},
);
});

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { import {
BASE_CHUNK_RATIO, BASE_CHUNK_RATIO,
@@ -169,11 +170,22 @@ async function readWorkspaceContextForSummary(): Promise<string> {
const agentsPath = path.join(workspaceDir, "AGENTS.md"); const agentsPath = path.join(workspaceDir, "AGENTS.md");
try { try {
if (!fs.existsSync(agentsPath)) { const opened = await openBoundaryFile({
absolutePath: agentsPath,
rootPath: workspaceDir,
boundaryLabel: "workspace root",
});
if (!opened.ok) {
return ""; return "";
} }
const content = await fs.promises.readFile(agentsPath, "utf-8"); const content = (() => {
try {
return fs.readFileSync(opened.fd, "utf-8");
} finally {
fs.closeSync(opened.fd);
}
})();
const sections = extractSections(content, ["Session Startup", "Red Lines"]); const sections = extractSections(content, ["Session Startup", "Red Lines"]);
if (sections.length === 0) { if (sections.length === 0) {
@@ -392,6 +404,7 @@ export const __testing = {
formatToolFailuresSection, formatToolFailuresSection,
computeAdaptiveChunkRatio, computeAdaptiveChunkRatio,
isOversizedForSummary, isOversizedForSummary,
readWorkspaceContextForSummary,
BASE_CHUNK_RATIO, BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO, MIN_CHUNK_RATIO,
SAFETY_MARGIN, SAFETY_MARGIN,

View File

@@ -0,0 +1,76 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_AGENTS_FILENAME } from "../workspace.js";
import { ensureSandboxWorkspace } from "./workspace.js";
const tempRoots: string[] = [];
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-workspace-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
});
describe("ensureSandboxWorkspace", () => {
it("seeds regular bootstrap files from the source workspace", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(path.join(seed, DEFAULT_AGENTS_FILENAME), "seeded-agents", "utf-8");
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8")).resolves.toBe(
"seeded-agents",
);
});
it.runIf(process.platform !== "win32")("skips symlinked bootstrap seed files", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
const outside = path.join(root, "outside-secret.txt");
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(outside, "secret", "utf-8");
await fs.symlink(outside, path.join(seed, DEFAULT_AGENTS_FILENAME));
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
).rejects.toBeDefined();
});
it.runIf(process.platform !== "win32")("skips hardlinked bootstrap seed files", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
const outside = path.join(root, "outside-agents.txt");
const linkedSeed = path.join(seed, DEFAULT_AGENTS_FILENAME);
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(outside, "outside", "utf-8");
try {
await fs.link(outside, linkedSeed);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw error;
}
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
).rejects.toBeDefined();
});
});

View File

@@ -1,5 +1,7 @@
import syncFs from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { resolveUserPath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
import { import {
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@@ -36,8 +38,20 @@ export async function ensureSandboxWorkspace(
await fs.access(dest); await fs.access(dest);
} catch { } catch {
try { try {
const content = await fs.readFile(src, "utf-8"); const opened = await openBoundaryFile({
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" }); absolutePath: src,
rootPath: seed,
boundaryLabel: "sandbox seed workspace",
});
if (!opened.ok) {
continue;
}
try {
const content = syncFs.readFileSync(opened.fd, "utf-8");
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
} finally {
syncFs.closeSync(opened.fd);
}
} catch { } catch {
// ignore missing seed file // ignore missing seed file
} }

View File

@@ -166,4 +166,28 @@ Never do Y.
expect(result).toContain("Rule 2"); expect(result).toContain("Rule 2");
expect(result).not.toContain("Other Section"); expect(result).not.toContain("Other Section");
}); });
it.runIf(process.platform !== "win32")(
"returns null when AGENTS.md is a symlink escaping workspace",
async () => {
const outside = path.join(tmpDir, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.symlinkSync(outside, path.join(tmpDir, "AGENTS.md"));
const result = await readPostCompactionContext(tmpDir);
expect(result).toBeNull();
},
);
it.runIf(process.platform !== "win32")(
"returns null when AGENTS.md is a hardlink alias",
async () => {
const outside = path.join(tmpDir, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.linkSync(outside, path.join(tmpDir, "AGENTS.md"));
const result = await readPostCompactionContext(tmpDir);
expect(result).toBeNull();
},
);
}); });

View File

@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
const MAX_CONTEXT_CHARS = 3000; const MAX_CONTEXT_CHARS = 3000;
@@ -11,11 +12,21 @@ export async function readPostCompactionContext(workspaceDir: string): Promise<s
const agentsPath = path.join(workspaceDir, "AGENTS.md"); const agentsPath = path.join(workspaceDir, "AGENTS.md");
try { try {
if (!fs.existsSync(agentsPath)) { const opened = await openBoundaryFile({
absolutePath: agentsPath,
rootPath: workspaceDir,
boundaryLabel: "workspace root",
});
if (!opened.ok) {
return null; return null;
} }
const content = (() => {
const content = await fs.promises.readFile(agentsPath, "utf-8"); try {
return fs.readFileSync(opened.fd, "utf-8");
} finally {
fs.closeSync(opened.fd);
}
})();
// Extract "## Session Startup" and "## Red Lines" sections // Extract "## Session Startup" and "## Red Lines" sections
// Each section ends at the next "## " heading or end of file // Each section ends at the next "## " heading or end of file