fix(qqbot): reject unsafe mention patterns (#102976)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NIO
2026-07-13 09:14:01 +08:00
committed by GitHub
parent a948168690
commit c93f87756a
2 changed files with 116 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
// Qqbot tests cover mention plugin behavior.
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
detectWasMentioned,
hasAnyMention,
@@ -7,6 +7,15 @@ import {
stripMentionText,
} from "./mention.js";
vi.mock("../utils/log.js", () => ({
debugWarn: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("engine/group/mention", () => {
describe("detectWasMentioned", () => {
it("returns true when mentions contains is_you", () => {
@@ -39,6 +48,50 @@ describe("engine/group/mention", () => {
expect(detectWasMentioned({ content: "hi", mentionPatterns: ["[", "@bot"] })).toBe(false);
});
it("rejects ReDoS patterns via compileSafeRegexDetailed guard", () => {
// "(a+)+" has nested repetition — catastrophic backtracking on long input.
// The guard must reject it (return false) rather than hang.
const longInput = "a".repeat(64);
expect(detectWasMentioned({ content: longInput, mentionPatterns: ["(a+)+$"] })).toBe(false);
});
it("still matches safe patterns after a rejected unsafe one", () => {
// Unsafe pattern is skipped; the next safe pattern should still match.
expect(
detectWasMentioned({
content: "hello @bot",
mentionPatterns: ["(a+)+$", "@bot"],
}),
).toBe(true);
});
it("emits a debugWarn with the rejection reason for each rejected pattern", async () => {
const { debugWarn } = await import("../utils/log.js");
detectWasMentioned({
content: "hi",
mentionPatterns: ["(b+)+$", "[invalid", "@safe"],
});
expect(debugWarn).toHaveBeenCalledTimes(2);
expect(vi.mocked(debugWarn).mock.calls[0]![0]).toContain("unsafe-nested-repetition");
expect(vi.mocked(debugWarn).mock.calls[0]![0]).toMatch(/\(b\+\)\+\$/);
expect(vi.mocked(debugWarn).mock.calls[1]![0]).toContain("invalid-regex");
expect(vi.mocked(debugWarn).mock.calls[1]![0]).toMatch(/\[invalid/);
});
it("does not re-warn rejected mentionPatterns on every message", async () => {
const { debugWarn } = await import("../utils/log.js");
const input = {
content: "hi",
mentionPatterns: ["(c+)+$", "@safe"],
};
detectWasMentioned(input);
detectWasMentioned(input);
expect(debugWarn).toHaveBeenCalledTimes(1);
expect(vi.mocked(debugWarn).mock.calls[0]![0]).toMatch(/\(c\+\)\+\$/);
});
it("matches case-insensitively", () => {
expect(detectWasMentioned({ content: "Hello @Bot", mentionPatterns: ["@bot"] })).toBe(true);
});

View File

@@ -1,4 +1,9 @@
// Qqbot plugin module implements mention behavior.
import {
compileSafeRegexDetailed,
type SafeRegexRejectReason,
} from "openclaw/plugin-sdk/security-runtime";
import { debugWarn } from "../utils/log.js";
export interface RawMention {
is_you?: boolean;
bot?: boolean;
@@ -23,6 +28,60 @@ interface HasAnyMentionInput {
}
const MENTION_TAG_RE = /<@!?\w+>/;
const MENTION_PATTERN_FLAGS = "i";
const MAX_MENTION_PATTERN_CACHE_KEYS = 256;
const MAX_MENTION_PATTERN_WARNING_KEYS = 256;
const mentionPatternCompileCache = new Map<string, RegExp[]>();
const rejectedMentionPatternWarningCache = new Set<string>();
type MentionPatternRejectReason = Exclude<SafeRegexRejectReason, "empty">;
function warnRejectedMentionPattern(pattern: string, reason: MentionPatternRejectReason): void {
const key = `${MENTION_PATTERN_FLAGS}::${reason}::${pattern}`;
if (rejectedMentionPatternWarningCache.has(key)) {
return;
}
rejectedMentionPatternWarningCache.add(key);
if (rejectedMentionPatternWarningCache.size > MAX_MENTION_PATTERN_WARNING_KEYS) {
rejectedMentionPatternWarningCache.clear();
rejectedMentionPatternWarningCache.add(key);
}
debugWarn(`qqbot: mentionPattern rejected (${reason}): ${pattern}`);
}
function cacheMentionPatterns(cacheKey: string, regexes: RegExp[]): RegExp[] {
mentionPatternCompileCache.set(cacheKey, regexes);
if (mentionPatternCompileCache.size > MAX_MENTION_PATTERN_CACHE_KEYS) {
mentionPatternCompileCache.clear();
mentionPatternCompileCache.set(cacheKey, regexes);
}
return regexes;
}
function compileMentionPatterns(patterns: string[]): RegExp[] {
if (patterns.length === 0) {
return [];
}
const cacheKey = patterns.join("\u001f");
const cached = mentionPatternCompileCache.get(cacheKey);
if (cached) {
return cached;
}
const regexes: RegExp[] = [];
for (const pattern of patterns) {
const result = compileSafeRegexDetailed(pattern, MENTION_PATTERN_FLAGS);
if (result.reason === "empty") {
continue;
}
if (result.regex) {
regexes.push(result.regex);
continue;
}
warnRejectedMentionPattern(result.source, result.reason);
}
return cacheMentionPatterns(cacheKey, regexes);
}
export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
const { eventType, mentions, content, mentionPatterns } = input;
@@ -36,15 +95,10 @@ export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
}
if (mentionPatterns?.length && content) {
for (const pattern of mentionPatterns) {
if (!pattern) {
continue;
for (const regex of compileMentionPatterns(mentionPatterns)) {
if (regex.test(content)) {
return true;
}
try {
if (new RegExp(pattern, "i").test(content)) {
return true;
}
} catch {}
}
}