mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(file-transfer): third-round PR review feedback
Aisle's re-analysis on b63daa6a05 surfaced 3 actionable findings: - nodes.invoke bypass (HIGH CWE-285): generic nodes.action="invoke" let agents call dir.list/dir.fetch/file.write directly, skipping the file-transfer plugin's gatekeep + policy + approval flow. Only file.fetch was redirected to its dedicated tool. Add the other three to MEDIA_INVOKE_ACTIONS so the redirect-or-deny logic in nodes-tool-commands fires for all four. The dedicated tools enforce policy; the generic invoke surface no longer has a way to skip them without an explicit allowMediaInvokeCommands opt-in. - prototype pollution in persistAllowAlways (MED CWE-1321): a paired node with displayName "__proto__" / "prototype" / "constructor" would mutate the fileTransfer object's prototype when persisting allow-always. Reject those keys explicitly. Switch the existing-key lookup to Object.prototype.hasOwnProperty.call so a key like "constructor" doesn't accidentally match Object.prototype.constructor. - decompression-bomb cap in dir_fetch (MED CWE-409): compressed tar is bounded upstream, but a highly compressible bomb can still expand to gigabytes. Enforce DIR_FETCH_MAX_UNCOMPRESSED_BYTES (64MB) summed across extracted files and DIR_FETCH_MAX_SINGLE_FILE_BYTES (16MB) per entry, both checked during the post-extract walk. On bust, rm -rf the rootDir and audit-log + throw UNCOMPRESSED_TOO_LARGE. Tests: 85/85 passing (added prototype-pollution rejection test). Aisle's HIGH parent-symlink finding remains documented as deferred — full rollback requires a node-side file.unlink command which is out of scope for this PR. The gateway-side post-flight policy check still detects and loudly errors on canonical-path mismatches.
This commit is contained in:
@@ -325,6 +325,32 @@ describe("persistAllowAlways", () => {
|
|||||||
expect(root.gateway.nodes.fileTransfer["Lobster"].allowReadPaths).toEqual(["/srv/added.png"]);
|
expect(root.gateway.nodes.fileTransfer["Lobster"].allowReadPaths).toEqual(["/srv/added.png"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe keys (__proto__, prototype, constructor) that would mutate prototype chain", async () => {
|
||||||
|
mutateConfigFileMock.mockImplementation(
|
||||||
|
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
|
||||||
|
const draft: Record<string, unknown> = {};
|
||||||
|
mutate(draft);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
persistAllowAlways({
|
||||||
|
nodeId: "n1",
|
||||||
|
nodeDisplayName: "__proto__",
|
||||||
|
kind: "read",
|
||||||
|
path: "/etc/passwd",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/unsafe key.*__proto__/);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
persistAllowAlways({
|
||||||
|
nodeId: "constructor",
|
||||||
|
kind: "read",
|
||||||
|
path: "/etc/passwd",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/unsafe key.*constructor/);
|
||||||
|
});
|
||||||
|
|
||||||
it("dedupes when path already present", async () => {
|
it("dedupes when path already present", async () => {
|
||||||
let captured: Record<string, unknown> | null = null;
|
let captured: Record<string, unknown> | null = null;
|
||||||
mutateConfigFileMock.mockImplementation(
|
mutateConfigFileMock.mockImplementation(
|
||||||
|
|||||||
@@ -264,6 +264,20 @@ export function evaluateFilePolicy(input: {
|
|||||||
* the "*" wildcard if that's what was hit). If no entry exists yet,
|
* the "*" wildcard if that's what was hit). If no entry exists yet,
|
||||||
* creates one keyed by nodeDisplayName ?? nodeId.
|
* creates one keyed by nodeDisplayName ?? nodeId.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Reject special object keys that would mutate the prototype chain when
|
||||||
|
* used as a property name (e.g. `__proto__` setter on a plain object).
|
||||||
|
* The nodeDisplayName comes from paired-node metadata which we don't
|
||||||
|
* fully control; refuse to persist policy under a key that could corrupt
|
||||||
|
* the fileTransfer container's prototype.
|
||||||
|
*/
|
||||||
|
function assertSafeConfigKey(key: string): string {
|
||||||
|
if (key === "__proto__" || key === "prototype" || key === "constructor") {
|
||||||
|
throw new Error(`refusing to persist file-transfer policy under unsafe key: ${key}`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistAllowAlways(input: {
|
export async function persistAllowAlways(input: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeDisplayName?: string;
|
nodeDisplayName?: string;
|
||||||
@@ -288,9 +302,11 @@ export async function persistAllowAlways(input: {
|
|||||||
const candidates = [input.nodeId, input.nodeDisplayName].filter(
|
const candidates = [input.nodeId, input.nodeDisplayName].filter(
|
||||||
(k): k is string => typeof k === "string" && k.length > 0,
|
(k): k is string => typeof k === "string" && k.length > 0,
|
||||||
);
|
);
|
||||||
let key = candidates.find((c) => fileTransfer[c]);
|
// Use hasOwnProperty so a node with displayName "constructor" doesn't
|
||||||
|
// accidentally hit Object.prototype.constructor and pretend to match.
|
||||||
|
let key = candidates.find((c) => Object.prototype.hasOwnProperty.call(fileTransfer, c));
|
||||||
if (!key) {
|
if (!key) {
|
||||||
key = input.nodeDisplayName ?? input.nodeId;
|
key = assertSafeConfigKey(input.nodeDisplayName ?? input.nodeId);
|
||||||
fileTransfer[key] = {};
|
fileTransfer[key] = {};
|
||||||
}
|
}
|
||||||
const entry = fileTransfer[key];
|
const entry = fileTransfer[key];
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ const TAR_UNPACK_TIMEOUT_MS = 60_000;
|
|||||||
// that walk can do.
|
// that walk can do.
|
||||||
const TAR_UNPACK_MAX_ENTRIES = 5000;
|
const TAR_UNPACK_MAX_ENTRIES = 5000;
|
||||||
|
|
||||||
|
// Hard caps on uncompressed extraction. Defends against decompression-bomb
|
||||||
|
// archives that compress to <16MB but expand to gigabytes. Both caps are
|
||||||
|
// enforced during the post-extract walk: total bytes summed across entries
|
||||||
|
// and per-file size to bound any single fs.stat / hash operation.
|
||||||
|
const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
||||||
|
const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
const DirFetchToolSchema = Type.Object({
|
const DirFetchToolSchema = Type.Object({
|
||||||
node: Type.String({
|
node: Type.String({
|
||||||
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
|
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
|
||||||
@@ -544,6 +551,28 @@ export function createDirFetchTool(): AnyAgentTool {
|
|||||||
|
|
||||||
const walked = await walkDir(rootDir, rootDir);
|
const walked = await walkDir(rootDir, rootDir);
|
||||||
const files: UnpackedFileEntry[] = [];
|
const files: UnpackedFileEntry[] = [];
|
||||||
|
// Defense-in-depth budget on the *uncompressed* extraction. Compressed
|
||||||
|
// tar is bounded upstream; an attacker can still send a highly
|
||||||
|
// compressible bomb (gigabytes of zeros) that fits under that cap.
|
||||||
|
// Stop walking + clean up if the unpacked tree busts the budget.
|
||||||
|
let totalUncompressed = 0;
|
||||||
|
const abortAndCleanup = async (reason: string): Promise<never> => {
|
||||||
|
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
await appendFileTransferAudit({
|
||||||
|
op: "dir.fetch",
|
||||||
|
nodeId,
|
||||||
|
nodeDisplayName,
|
||||||
|
requestedPath: dirPath,
|
||||||
|
canonicalPath,
|
||||||
|
decision: "error",
|
||||||
|
errorCode: "TREE_TOO_LARGE",
|
||||||
|
errorMessage: reason,
|
||||||
|
sizeBytes: tarBytes,
|
||||||
|
sha256,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
throw new Error(`dir.fetch UNCOMPRESSED_TOO_LARGE: ${reason}`);
|
||||||
|
};
|
||||||
for (const { relPath, absPath } of walked) {
|
for (const { relPath, absPath } of walked) {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
try {
|
try {
|
||||||
@@ -552,6 +581,17 @@ export function createDirFetchTool(): AnyAgentTool {
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (size > DIR_FETCH_MAX_SINGLE_FILE_BYTES) {
|
||||||
|
await abortAndCleanup(
|
||||||
|
`extracted file ${relPath} is ${size} bytes (limit ${DIR_FETCH_MAX_SINGLE_FILE_BYTES})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
totalUncompressed += size;
|
||||||
|
if (totalUncompressed > DIR_FETCH_MAX_UNCOMPRESSED_BYTES) {
|
||||||
|
await abortAndCleanup(
|
||||||
|
`extracted tree exceeds uncompressed budget ${DIR_FETCH_MAX_UNCOMPRESSED_BYTES} bytes (decompression bomb?)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const mimeType = mimeFromExtension(relPath);
|
const mimeType = mimeFromExtension(relPath);
|
||||||
const fileSha256 = await computeFileSha256(absPath);
|
const fileSha256 = await computeFileSha256(absPath);
|
||||||
files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath });
|
files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath });
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ export const MEDIA_INVOKE_ACTIONS = {
|
|||||||
"camera.clip": "camera_clip",
|
"camera.clip": "camera_clip",
|
||||||
"photos.latest": "photos_latest",
|
"photos.latest": "photos_latest",
|
||||||
"screen.record": "screen_record",
|
"screen.record": "screen_record",
|
||||||
|
// file-transfer commands: redirect to dedicated tools so the path policy
|
||||||
|
// + operator approval flow always runs. Without this, an agent could
|
||||||
|
// call them via the generic nodes.action="invoke" surface and skip
|
||||||
|
// gatekeep() entirely.
|
||||||
"file.fetch": "file_fetch",
|
"file.fetch": "file_fetch",
|
||||||
|
"dir.list": "dir_list",
|
||||||
|
"dir.fetch": "dir_fetch",
|
||||||
|
"file.write": "file_write",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type NodeMediaAction = "camera_snap" | "photos_latest" | "camera_clip" | "screen_record";
|
export type NodeMediaAction = "camera_snap" | "photos_latest" | "camera_clip" | "screen_record";
|
||||||
|
|||||||
Reference in New Issue
Block a user