mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 02:22:25 +00:00
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:
74
extensions/qqbot/src/reply-dispatcher.test.ts
Normal file
74
extensions/qqbot/src/reply-dispatcher.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
clearTokenCache: vi.fn(),
|
||||
getAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
sendC2CFileMessage: vi.fn(),
|
||||
sendC2CImageMessage: vi.fn(),
|
||||
sendC2CMessage: vi.fn(),
|
||||
sendC2CVideoMessage: vi.fn(),
|
||||
sendC2CVoiceMessage: vi.fn(),
|
||||
sendChannelMessage: vi.fn(),
|
||||
sendDmMessage: vi.fn(),
|
||||
sendGroupFileMessage: vi.fn(),
|
||||
sendGroupImageMessage: vi.fn(),
|
||||
sendGroupMessage: vi.fn(),
|
||||
sendGroupVideoMessage: vi.fn(),
|
||||
sendGroupVoiceMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => apiMocks);
|
||||
|
||||
import { handleStructuredPayload, type ReplyContext } from "./reply-dispatcher.js";
|
||||
|
||||
function buildCtx(): ReplyContext {
|
||||
return {
|
||||
target: {
|
||||
type: "c2c",
|
||||
senderId: "user-1",
|
||||
messageId: "msg-1",
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
appId: "app-id",
|
||||
clientSecret: "secret",
|
||||
config: {},
|
||||
} as ReplyContext["account"],
|
||||
cfg: {},
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("qqbot reply dispatcher", () => {
|
||||
it("allows inline data image URLs for structured image payloads", async () => {
|
||||
const ctx = buildCtx();
|
||||
const recordActivity = vi.fn();
|
||||
const dataUrl = "data:image/png;base64,Zm9v";
|
||||
|
||||
const handled = await handleStructuredPayload(
|
||||
ctx,
|
||||
`QQBOT_PAYLOAD:${JSON.stringify({
|
||||
type: "media",
|
||||
mediaType: "image",
|
||||
source: "url",
|
||||
path: dataUrl,
|
||||
})}`,
|
||||
recordActivity,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(recordActivity).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledWith(
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
dataUrl,
|
||||
"msg-1",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { textToSpeech as globalTextToSpeech } from "openclaw/plugin-sdk/speech-runtime";
|
||||
@@ -25,12 +27,7 @@ import {
|
||||
audioFileToSilkBase64,
|
||||
formatDuration,
|
||||
} from "./utils/audio-convert.js";
|
||||
import {
|
||||
checkFileSize,
|
||||
readFileAsync,
|
||||
fileExistsAsync,
|
||||
formatFileSize,
|
||||
} from "./utils/file-utils.js";
|
||||
import { MAX_UPLOAD_SIZE, formatFileSize } from "./utils/file-utils.js";
|
||||
import {
|
||||
parseQQBotPayload,
|
||||
encodePayloadForCron,
|
||||
@@ -41,7 +38,7 @@ import {
|
||||
import {
|
||||
getQQBotDataDir,
|
||||
normalizePath,
|
||||
resolveQQBotLocalMediaPath,
|
||||
resolveQQBotPayloadLocalFilePath,
|
||||
sanitizeFileName,
|
||||
} from "./utils/platform.js";
|
||||
|
||||
@@ -194,23 +191,92 @@ export async function handleStructuredPayload(
|
||||
|
||||
// Media payload handlers.
|
||||
|
||||
function validateStructuredPayloadLocalPath(
|
||||
ctx: ReplyContext,
|
||||
payloadPath: string,
|
||||
mediaType: "image" | "video" | "file",
|
||||
): string | null {
|
||||
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
|
||||
if (allowedPath) {
|
||||
return allowedPath;
|
||||
}
|
||||
|
||||
ctx.log?.error(
|
||||
`[qqbot:${ctx.account.accountId}] Blocked ${mediaType} payload local path outside QQ Bot media storage`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRemoteHttpUrl(p: string): boolean {
|
||||
return p.startsWith("http://") || p.startsWith("https://");
|
||||
}
|
||||
|
||||
function isInlineImageDataUrl(p: string): boolean {
|
||||
return /^data:image\/[^;]+;base64,/i.test(p);
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string, maxLen = 200): string {
|
||||
return value.replace(/[\r\n\t\0]/g, " ").slice(0, maxLen);
|
||||
}
|
||||
|
||||
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
|
||||
if (!isHttpUrl) {
|
||||
return "<local-file>";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(pathValue);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
|
||||
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
|
||||
} catch {
|
||||
return "<invalid-url>";
|
||||
}
|
||||
}
|
||||
|
||||
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
|
||||
const openFlags =
|
||||
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
|
||||
const handle = await fs.promises.open(filePath, openFlags);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Path is not a regular file");
|
||||
}
|
||||
if (stat.size > MAX_UPLOAD_SIZE) {
|
||||
throw new Error(
|
||||
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
|
||||
);
|
||||
}
|
||||
return handle.readFile();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
let imageUrl = resolveQQBotLocalMediaPath(normalizePath(payload.path));
|
||||
const normalizedPath = normalizePath(payload.path);
|
||||
let imageUrl: string | null;
|
||||
if (payload.source === "file") {
|
||||
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
|
||||
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
|
||||
imageUrl = normalizedPath;
|
||||
} else {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!imageUrl) {
|
||||
return;
|
||||
}
|
||||
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
|
||||
|
||||
if (payload.source === "file") {
|
||||
try {
|
||||
if (!(await fileExistsAsync(imageUrl))) {
|
||||
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
||||
return;
|
||||
}
|
||||
const imgSzCheck = checkFileSize(imageUrl);
|
||||
if (!imgSzCheck.ok) {
|
||||
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
||||
return;
|
||||
}
|
||||
const fileBuffer = await readFileAsync(imageUrl);
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
|
||||
const base64Data = fileBuffer.toString("base64");
|
||||
const ext = path.extname(imageUrl).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
@@ -405,90 +471,93 @@ async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Pro
|
||||
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
try {
|
||||
const videoPath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? ""));
|
||||
if (!videoPath?.trim()) {
|
||||
const originalPath = payload.path ?? "";
|
||||
const normalizedPath = normalizePath(originalPath);
|
||||
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
||||
const videoPath = isHttpUrl
|
||||
? normalizedPath
|
||||
: validateStructuredPayloadLocalPath(ctx, originalPath, "video");
|
||||
if (!videoPath) {
|
||||
return;
|
||||
}
|
||||
if (!videoPath.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video missing path`);
|
||||
} else {
|
||||
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
||||
log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
} else {
|
||||
if (!(await fileExistsAsync(videoPath))) {
|
||||
throw new Error(`Video file does not exist: ${videoPath}`);
|
||||
}
|
||||
const vPaySzCheck = checkFileSize(videoPath);
|
||||
if (!vPaySzCheck.ok) {
|
||||
throw new Error(vPaySzCheck.error!);
|
||||
}
|
||||
const fileBuffer = await readFileAsync(videoPath);
|
||||
const videoBase64 = fileBuffer.toString("base64");
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`,
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
undefined,
|
||||
videoPath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] Video message sent`);
|
||||
} else {
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
|
||||
const videoBase64 = fileBuffer.toString("base64");
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
|
||||
);
|
||||
|
||||
if (payload.caption) {
|
||||
await sendTextToTarget(ctx, payload.caption);
|
||||
}
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
undefined,
|
||||
videoPath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] Video message sent`);
|
||||
|
||||
if (payload.caption) {
|
||||
await sendTextToTarget(ctx, payload.caption);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
||||
@@ -498,89 +567,90 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro
|
||||
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
try {
|
||||
const filePath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? ""));
|
||||
if (!filePath?.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
||||
} else {
|
||||
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
|
||||
const fileName = sanitizeFileName(path.basename(filePath));
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${isHttpUrl ? "URL" : "local"})`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
} else {
|
||||
if (!(await fileExistsAsync(filePath))) {
|
||||
throw new Error(`File does not exist: ${filePath}`);
|
||||
}
|
||||
const fPaySzCheck = checkFileSize(filePath);
|
||||
if (!fPaySzCheck.ok) {
|
||||
throw new Error(fPaySzCheck.error!);
|
||||
}
|
||||
const fileBuffer = await readFileAsync(filePath);
|
||||
const fileBase64 = fileBuffer.toString("base64");
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
filePath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] File message sent`);
|
||||
const originalPath = payload.path ?? "";
|
||||
const normalizedPath = normalizePath(originalPath);
|
||||
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
||||
const filePath = isHttpUrl
|
||||
? normalizedPath
|
||||
: validateStructuredPayloadLocalPath(ctx, originalPath, "file");
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
if (!filePath.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = sanitizeFileName(path.basename(filePath));
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
} else {
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
|
||||
const fileBase64 = fileBuffer.toString("base64");
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
filePath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] File message sent`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user