mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:20:44 +00:00
fix(sandbox): normalize /workspace media paths to host sandbox root
Co-authored-by: echo931 <echo931@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
|
- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
|
||||||
- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
|
- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
|
||||||
- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo.
|
- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo.
|
||||||
|
- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
|
||||||
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
|
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
|
||||||
- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
|
- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
|
||||||
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756)
|
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756)
|
||||||
|
|||||||
@@ -62,6 +62,26 @@ describe("resolveSandboxedMediaSource", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps container /workspace absolute paths into sandbox root", async () => {
|
||||||
|
await withSandboxRoot(async (sandboxDir) => {
|
||||||
|
const result = await resolveSandboxedMediaSource({
|
||||||
|
media: "/workspace/media/pic.png",
|
||||||
|
sandboxRoot: sandboxDir,
|
||||||
|
});
|
||||||
|
expect(result).toBe(path.join(sandboxDir, "media", "pic.png"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps file:// URLs under /workspace into sandbox root", async () => {
|
||||||
|
await withSandboxRoot(async (sandboxDir) => {
|
||||||
|
const result = await resolveSandboxedMediaSource({
|
||||||
|
media: "file:///workspace/media/pic.png",
|
||||||
|
sandboxRoot: sandboxDir,
|
||||||
|
});
|
||||||
|
expect(result).toBe(path.join(sandboxDir, "media", "pic.png"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Group 3: Rejections (security)
|
// Group 3: Rejections (security)
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
@@ -69,6 +89,11 @@ describe("resolveSandboxedMediaSource", () => {
|
|||||||
media: "/etc/passwd",
|
media: "/etc/passwd",
|
||||||
expected: /sandbox/i,
|
expected: /sandbox/i,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "paths under similarly named container roots",
|
||||||
|
media: "/workspace-two/secret.txt",
|
||||||
|
expected: /sandbox/i,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "path traversal through tmpdir",
|
name: "path traversal through tmpdir",
|
||||||
media: path.join(os.tmpdir(), "..", "etc", "passwd"),
|
media: path.join(os.tmpdir(), "..", "etc", "passwd"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
|||||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||||
const HTTP_URL_RE = /^https?:\/\//i;
|
const HTTP_URL_RE = /^https?:\/\//i;
|
||||||
const DATA_URL_RE = /^data:/i;
|
const DATA_URL_RE = /^data:/i;
|
||||||
|
const SANDBOX_CONTAINER_WORKDIR = "/workspace";
|
||||||
|
|
||||||
function normalizeUnicodeSpaces(str: string): string {
|
function normalizeUnicodeSpaces(str: string): string {
|
||||||
return str.replace(UNICODE_SPACES, " ");
|
return str.replace(UNICODE_SPACES, " ");
|
||||||
@@ -90,6 +91,13 @@ export async function resolveSandboxedMediaSource(params: {
|
|||||||
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
|
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const containerWorkspaceMapped = mapContainerWorkspacePath({
|
||||||
|
candidate,
|
||||||
|
sandboxRoot: params.sandboxRoot,
|
||||||
|
});
|
||||||
|
if (containerWorkspaceMapped) {
|
||||||
|
candidate = containerWorkspaceMapped;
|
||||||
|
}
|
||||||
const tmpMediaPath = await resolveAllowedTmpMediaPath({
|
const tmpMediaPath = await resolveAllowedTmpMediaPath({
|
||||||
candidate,
|
candidate,
|
||||||
sandboxRoot: params.sandboxRoot,
|
sandboxRoot: params.sandboxRoot,
|
||||||
@@ -105,6 +113,25 @@ export async function resolveSandboxedMediaSource(params: {
|
|||||||
return sandboxResult.resolved;
|
return sandboxResult.resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapContainerWorkspacePath(params: {
|
||||||
|
candidate: string;
|
||||||
|
sandboxRoot: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const normalized = params.candidate.replace(/\\/g, "/");
|
||||||
|
if (normalized === SANDBOX_CONTAINER_WORKDIR) {
|
||||||
|
return path.resolve(params.sandboxRoot);
|
||||||
|
}
|
||||||
|
const prefix = `${SANDBOX_CONTAINER_WORKDIR}/`;
|
||||||
|
if (!normalized.startsWith(prefix)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rel = normalized.slice(prefix.length);
|
||||||
|
if (!rel) {
|
||||||
|
return path.resolve(params.sandboxRoot);
|
||||||
|
}
|
||||||
|
return path.resolve(params.sandboxRoot, ...rel.split("/").filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveAllowedTmpMediaPath(params: {
|
async function resolveAllowedTmpMediaPath(params: {
|
||||||
candidate: string;
|
candidate: string;
|
||||||
sandboxRoot: string;
|
sandboxRoot: string;
|
||||||
|
|||||||
@@ -585,6 +585,27 @@ describe("runMessageAction sandboxed media validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rewrites /workspace media paths to host sandbox root", async () => {
|
||||||
|
await withSandbox(async (sandboxDir) => {
|
||||||
|
const result = await runDrySend({
|
||||||
|
cfg: slackConfig,
|
||||||
|
actionParams: {
|
||||||
|
channel: "slack",
|
||||||
|
target: "#C12345678",
|
||||||
|
media: "/workspace/data/file.txt",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
sandboxRoot: sandboxDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("send");
|
||||||
|
if (result.kind !== "send") {
|
||||||
|
throw new Error("expected send result");
|
||||||
|
}
|
||||||
|
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "file.txt"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rewrites MEDIA directives under sandbox", async () => {
|
it("rewrites MEDIA directives under sandbox", async () => {
|
||||||
await withSandbox(async (sandboxDir) => {
|
await withSandbox(async (sandboxDir) => {
|
||||||
const result = await runDrySend({
|
const result = await runDrySend({
|
||||||
|
|||||||
Reference in New Issue
Block a user