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

@@ -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,
);
});
});

View File

@@ -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}`);
}

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.
/**