qqbot: require explicit allowlist for /bot-logs to prevent info disclosure (#58895)

* qqbot: harden /bot-logs authorization fallback

* fix(qqbot): harden bot logs allowlist guard

* fix(qqbot): normalize bot logs allowlist entries
This commit is contained in:
Vincent Koc 2026-04-01 18:40:46 +09:00 committed by GitHub
parent 07c60ae461
commit 72af92ba4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 87 additions and 5 deletions

View File

@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/browser: compare browser runtime inspection against `agents.defaults.sandbox.browser.image` so `openclaw sandbox list --browser` stops reporting healthy browser containers as image mismatches. (#58759) Thanks @sandpile.
- Exec/approvals: resume the original agent session after an approved async exec finishes, so external completion followups continue the task instead of waiting for a new user turn. (#58860) Thanks @Nanako0129.
- Channels/plugins: keep bundled channel plugins loadable from legacy `channels.<id>` config even under restrictive plugin allowlists, and make `openclaw doctor` warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus
- Channels/QQ Bot: keep `/bot-logs` export gated behind a truly explicit QQBot allowlist, rejecting wildcard and mixed wildcard entries while preserving the real framework command path. Thanks @vincentkoc.
- Auto-reply/commands: strip inbound metadata before slash command detection so wrapped `/model`, `/new`, and `/status` commands are recognized. (#58725) Thanks @Mlightsnow.
- Gateway/nodes: stop pinning live node commands to the approved node-pair record. Node pairing remains a trust/token flow, while per-node `system.run` policy stays in that node's exec approvals config. Fixes #58824.
- WebChat/exec approvals: use native approval UI guidance in agent system prompts instead of telling agents to paste manual `/approve` commands in webchat sessions. Thanks @vincentkoc.

View File

@ -56,6 +56,7 @@ export default defineChannelPluginEntry({
: rawMsgType === "dm"
? "dm"
: "c2c";
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
// Build a minimal SlashCommandContext from the framework PluginCommandContext.
// commandAuthorized is always true here because the framework has already
@ -68,10 +69,11 @@ export default defineChannelPluginEntry({
receivedAt: Date.now(),
rawContent: `/${cmd.name}${ctx.args ? ` ${ctx.args}` : ""}`,
args: ctx.args ?? "",
accountId: ctx.accountId ?? "default",
accountId: account.accountId,
// appId is not available from PluginCommandContext directly; handlers
// that need it should call resolveQQBotAccount(ctx.config, ctx.accountId).
appId: "",
appId: account.appId,
accountConfig: account.config,
commandAuthorized: true,
queueSnapshot: {
totalPending: 0,
@ -91,7 +93,6 @@ export default defineChannelPluginEntry({
// File result: send the file attachment via QQ API, return text summary.
if (result && "filePath" in result) {
try {
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const mediaCtx: MediaTargetContext = {
targetType,
targetId,

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { matchSlashCommand, type SlashCommandContext } from "./slash-commands.js";
import {
getFrameworkCommands,
matchSlashCommand,
type SlashCommandContext,
} from "./slash-commands.js";
/** Build a minimal SlashCommandContext for testing. */
function buildCtx(overrides: Partial<SlashCommandContext> = {}): SlashCommandContext {
@ -110,3 +114,58 @@ describe("slash command authorization", () => {
// ---- usage query (?) for remaining pre-dispatch commands ----
});
describe("/bot-logs framework command hardening", () => {
function getBotLogsHandler() {
const command = getFrameworkCommands().find((item) => item.name === "bot-logs");
expect(command).toBeDefined();
return command!.handler;
}
it("rejects /bot-logs when allowFrom is wildcard", async () => {
const handler = getBotLogsHandler();
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*"] } }));
expect(result).toBeTypeOf("string");
expect(result as string).toContain("权限不足");
});
it("rejects /bot-logs when allowFrom mixes wildcard and explicit entries", async () => {
const handler = getBotLogsHandler();
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*", "qqbot:user-1"] } }));
expect(result).toBeTypeOf("string");
expect(result as string).toContain("权限不足");
});
it("rejects /bot-logs when allowFrom uses qqbot:* wildcard form", async () => {
const handler = getBotLogsHandler();
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:*"] } }));
expect(result).toBeTypeOf("string");
expect(result as string).toContain("权限不足");
});
it("rejects /bot-logs when allowFrom uses qqbot: * wildcard form", async () => {
const handler = getBotLogsHandler();
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot: *"] } }));
expect(result).toBeTypeOf("string");
expect(result as string).toContain("权限不足");
});
it("allows /bot-logs when allowFrom contains numeric sender ids", async () => {
const handler = getBotLogsHandler();
const accountConfig = { allowFrom: [12345] } as unknown as SlashCommandContext["accountConfig"];
const result = await handler(buildCtx({ accountConfig }));
expect(result).not.toBeNull();
expect(result).not.toBe(
"⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。",
);
});
it("allows /bot-logs execution when allowFrom is explicit", async () => {
const handler = getBotLogsHandler();
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:user-1"] } }));
expect(result).not.toBeNull();
expect(result).not.toBe(
"⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。",
);
});
});

View File

@ -108,6 +108,20 @@ export interface QQBotFrameworkCommand {
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
function hasExplicitCommandAllowlist(accountConfig?: QQBotAccountConfig): boolean {
const allowFrom = accountConfig?.allowFrom;
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
return false;
}
return allowFrom.every((entry) => {
const normalized = String(entry)
.trim()
.replace(/^qqbot:\s*/i, "")
.trim();
return normalized.length > 0 && normalized !== "*";
});
}
// ============ Command registry ============
// Pre-dispatch commands (requireAuth: false) — handled immediately before queuing.
@ -529,7 +543,14 @@ registerCommand({
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
`每个文件只保留最后 1000 行,并作为附件返回。`,
].join("\n"),
handler: () => buildBotLogsResult(),
handler: (ctx) => {
// Defense in depth: require an explicit QQ allowlist entry for log export.
// This keeps `/bot-logs` closed when setup leaves allowFrom in permissive mode.
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。`;
}
return buildBotLogsResult();
},
});
// Slash command entry point.