fix(qqbot): restrict structured payload local paths (#58453)

* fix(qqbot): restrict structured payload local paths

* fix(qqbot): narrow structured payload file access

* test(qqbot): cover payload path traversal guards

* fix(qqbot): reduce structured payload log exposure

* fix(qqbot): preserve inline image payload URLs
This commit is contained in:
Jacob Tomlinson
2026-04-02 02:20:52 -07:00
committed by GitHub
parent 5c36c2d0d2
commit 2c45b06afd
4 changed files with 464 additions and 180 deletions

View File

@@ -1,7 +1,12 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getHomeDir, resolveQQBotLocalMediaPath } from "./platform.js";
import {
getHomeDir,
resolveQQBotLocalMediaPath,
resolveQQBotPayloadLocalFilePath,
} from "./platform.js";
describe("qqbot local media path remapping", () => {
const createdPaths: string[] = [];
@@ -31,6 +36,7 @@ describe("qqbot local media path remapping", () => {
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const missingWorkspacePath = path.join(
actualHome,
@@ -63,7 +69,110 @@ describe("qqbot local media path remapping", () => {
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile);
});
it("blocks structured payload files outside QQ Bot storage", () => {
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-platform-outside-"));
createdPaths.push(outsideRoot);
const outsideFile = path.join(outsideRoot, "secret.txt");
fs.writeFileSync(outsideFile, "secret", "utf8");
expect(resolveQQBotPayloadLocalFilePath(outsideFile)).toBeNull();
});
it("blocks structured payload paths that escape QQ Bot media via '..'", () => {
const escapedPath = path.join(
getHomeDir(),
".openclaw",
"media",
"qqbot",
"..",
"..",
"qqbot-escape.txt",
);
expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull();
});
it("allows structured payload files inside the QQ Bot media directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"allowed.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
});
it("blocks structured payload files inside the QQ Bot data directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const dataFile = path.join(
actualHome,
".openclaw",
"qqbot",
"sessions",
path.basename(testRoot),
"session.json",
);
fs.mkdirSync(path.dirname(dataFile), { recursive: true });
fs.writeFileSync(dataFile, "{}", "utf8");
createdPaths.push(path.dirname(dataFile));
expect(resolveQQBotPayloadLocalFilePath(dataFile)).toBeNull();
});
it("allows legacy workspace paths when they remap into QQ Bot media storage", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"legacy.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const missingWorkspacePath = path.join(
actualHome,
".openclaw",
"workspace",
"qqbot",
"downloads",
path.basename(testRoot),
"legacy.png",
);
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(mediaFile);
});
});

View File

@@ -154,6 +154,37 @@ export function resolveQQBotLocalMediaPath(p: string): string {
return normalized;
}
/**
* Resolve a structured-payload local file path and enforce that it stays within
* QQ Bot-owned storage roots.
*/
export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
const candidate = resolveQQBotLocalMediaPath(p);
if (!candidate.trim()) {
return null;
}
const resolvedCandidate = path.resolve(candidate);
if (!fs.existsSync(resolvedCandidate)) {
return null;
}
const canonicalCandidate = fs.realpathSync(resolvedCandidate);
const allowedRoots = [getQQBotMediaDir()];
for (const root of allowedRoots) {
const resolvedRoot = path.resolve(root);
const canonicalRoot = fs.existsSync(resolvedRoot)
? fs.realpathSync(resolvedRoot)
: resolvedRoot;
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
return canonicalCandidate;
}
}
return null;
}
// Filename normalization.
/**