mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 11:21:34 +00:00
fix(qqbot): reject unsafe mention patterns (#102976)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user