diff --git a/README.md b/README.md index 43be3fa..ab70815 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This repository provides: | ------ | ----------- | ------- | ------ | | [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.1 | stable | | [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.2 | stable | -| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.1 | stable | +| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.2 | stable | | [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.2 | stable | | [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.1 | stable | diff --git a/faq-bot/CHANGELOG.md b/faq-bot/CHANGELOG.md index b2f718d..af7628c 100644 --- a/faq-bot/CHANGELOG.md +++ b/faq-bot/CHANGELOG.md @@ -8,6 +8,18 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [0.1.2] — 2026-06-23 + +### Changed + +- `regex` rules are now validated for catastrophic-backtracking risk at parse time. A pattern that + nests an unbounded quantifier inside another (e.g. `(a+)+`, `(\w+\s?)*`) is skipped with a warning + like any other unusable pattern, so a single rule can no longer stall message handling on a crafted + input. Ordinary patterns — including lookahead and backreferences — are unaffected. +- The per-chat fallback cooldown now tracks usage as least-recently-used: re-inserting a chat on each + reply so a busy chat's cooldown is preserved when the map reaches its cap, instead of being evicted + by first-seen order. + ## [0.1.1] — 2026-06-23 ### Added diff --git a/faq-bot/README.md b/faq-bot/README.md index 0c6615a..1ec3149 100644 --- a/faq-bot/README.md +++ b/faq-bot/README.md @@ -13,7 +13,7 @@ | Field | Value | | ----- | ----- | | **Identifier** | `faq-bot` | -| **Version** | 0.1.1 | +| **Version** | 0.1.2 | | **Released** | 2026-06-23 | | **Status** | stable | | **Author** | Yudhi Armyndharis | diff --git a/faq-bot/index.test.ts b/faq-bot/index.test.ts index fb4df14..6103469 100644 --- a/faq-bot/index.test.ts +++ b/faq-bot/index.test.ts @@ -49,3 +49,13 @@ test('allowFallback caps the map at 5000 entries, dropping the oldest', () => { assert.equal(map.has('chat-0'), false); // oldest evicted assert.equal(map.has('chat-5000'), true); // newest kept }); + +test('allowFallback eviction is recency-aware: re-touching a key protects it from eviction', () => { + const map = new Map(); + for (let i = 0; i < 5000; i++) allowFallback(map, `chat-${i}`, i, 0); + allowFallback(map, 'chat-0', 10000, 0); // re-touch -> most recently used + allowFallback(map, 'chat-new', 10001, 0); // overflow -> evict genuinely-oldest + assert.equal(map.size, 5000); + assert.equal(map.has('chat-0'), true); // protected by recent touch + assert.equal(map.has('chat-1'), false); // now the oldest, evicted +}); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index 3a861e4..ed237ab 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -38,12 +38,13 @@ export function parseConfig(raw: Record): { } /** - * Decide whether a fallback may be sent to `key` now. On allow, records `nowMs` and caps the map by - * dropping the oldest entry (insertion order). A `cooldownMs` of 0 always allows. + * Decide whether a fallback may be sent to `key` now. On allow, records `nowMs` (re-inserting so the + * map evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows. */ export function allowFallback(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { const last = map.get(key); if (last !== undefined && nowMs - last < cooldownMs) return false; + map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) map.set(key, nowMs); if (map.size > MAX_COOLDOWN_ENTRIES) { const oldest = map.keys().next().value as string | undefined; diff --git a/faq-bot/manifest.json b/faq-bot/manifest.json index 78e445d..e1627ba 100644 --- a/faq-bot/manifest.json +++ b/faq-bot/manifest.json @@ -1,7 +1,7 @@ { "id": "faq-bot", "name": "FAQ / Auto-Reply Bot", - "version": "0.1.1", + "version": "0.1.2", "type": "extension", "main": "dist/index.js", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", diff --git a/faq-bot/rules.test.ts b/faq-bot/rules.test.ts index 9ea91e6..b68b1c5 100644 --- a/faq-bot/rules.test.ts +++ b/faq-bot/rules.test.ts @@ -31,6 +31,33 @@ test('parseRules skips an invalid regex but keeps valid rules', () => { assert.deepEqual(skipped, ['(']); }); +test('parseRules skips a catastrophic-backtracking regex (nested unbounded quantifiers)', () => { + const { rules, skipped } = parseRules( + JSON.stringify([ + { mode: 'regex', pattern: '(a+)+$', reply: 'evil' }, + { mode: 'regex', pattern: '(\\w+\\s?)*$', reply: 'evil2' }, + { mode: 'contains', pattern: 'hi', reply: 'hello' }, + ]), + ); + assert.equal(rules.length, 1); + assert.deepEqual(skipped, ['(a+)+$', '(\\w+\\s?)*$']); +}); + +test('parseRules keeps safe regexes (single/non-nested quantifiers, lookahead)', () => { + const { rules, skipped } = parseRules( + JSON.stringify([ + { mode: 'regex', pattern: '^/start', reply: 'a' }, + { mode: 'regex', pattern: '(abc)+', reply: 'b' }, + { mode: 'regex', pattern: '\\d{2,5}', reply: 'c' }, + { mode: 'regex', pattern: 'a*b*c*', reply: 'd' }, + { mode: 'regex', pattern: '(?=.*foo)', reply: 'e' }, + ]), + ); + assert.equal(skipped.length, 0); + assert.equal(rules.length, 5); + assert.equal(matchRule(rules, 'abcabc')?.reply, 'b'); +}); + test('matchRule: contains is case-insensitive substring; no match returns null', () => { const { rules } = parseRules(ok); assert.equal(matchRule(rules, 'Brp HARGAnya?')?.reply, 'Harga mulai 100rb'); diff --git a/faq-bot/rules.ts b/faq-bot/rules.ts index d60ab4f..e3b2ce6 100644 --- a/faq-bot/rules.ts +++ b/faq-bot/rules.ts @@ -9,8 +9,59 @@ export interface CompiledRule extends Rule { } const MODES: RuleMode[] = ['contains', 'exact', 'regex']; -/** Cap on the body length a regex is tested against — bounds ReDoS from an operator-authored pattern. */ +/** Cap on the body length a regex is tested against (defence in depth, not the ReDoS control). */ const MAX_REGEX_INPUT = 1000; +/** Reject absurdly long patterns outright. */ +const MAX_PATTERN_LENGTH = 1000; + +/** Unbounded quantifier (`*`, `+`, or open-ended `{n,}`) at position `i`; returns its source length. */ +function unboundedQuantifierAt(p: string, i: number): { unbounded: boolean; len: number } { + const c = p[i]; + if (c === '*' || c === '+') return { unbounded: true, len: 1 }; + if (c === '{') { + const close = p.indexOf('}', i); + if (close === -1) return { unbounded: false, len: 1 }; + const m = /^(\d+)(,(\d*))?$/.exec(p.slice(i + 1, close)); + if (!m) return { unbounded: false, len: 1 }; + return { unbounded: m[2] !== undefined && (m[3] ?? '') === '', len: close - i + 1 }; + } + return { unbounded: false, len: 0 }; +} + +/** + * Conservatively reject patterns prone to catastrophic backtracking — an unbounded quantifier applied + * to a group that itself contains an unbounded quantifier, e.g. `(a+)+`, `(\w+\s?)*`. Accepted patterns + * run on the native engine unchanged (full ECMAScript semantics). Does not catch every ReDoS class + * (e.g. overlapping alternation), but closes the dominant nested-quantifier class; fails closed. + */ +export function isSafeRegexPattern(p: string): boolean { + if (p.length > MAX_PATTERN_LENGTH) return false; + const stack: { hasUnbounded: boolean }[] = []; + let inClass = false; + for (let i = 0; i < p.length; i++) { + const c = p[i]; + if (c === '\\') { i++; continue; } // escaped atom + if (inClass) { if (c === ']') inClass = false; continue; } + if (c === '[') { inClass = true; continue; } + if (c === '(') { stack.push({ hasUnbounded: false }); continue; } + if (c === ')') { + const group = stack.pop() ?? { hasUnbounded: false }; + const q = unboundedQuantifierAt(p, i + 1); + if (q.unbounded) { + if (group.hasUnbounded) return false; // nested unbounded quantifier -> catastrophic + if (stack.length) stack[stack.length - 1].hasUnbounded = true; // quantified group repeats too + i += q.len; + } + continue; + } + const q = unboundedQuantifierAt(p, i); + if (q.unbounded) { + if (stack.length) stack[stack.length - 1].hasUnbounded = true; + i += q.len - 1; + } + } + return true; +} /** * Parse + validate the rules JSON. Throws on structurally invalid input (not JSON, not an array, a rule @@ -35,6 +86,10 @@ export function parseRules(json: string): { rules: CompiledRule[]; skipped: stri throw new Error(`rule ${i}: reply must be a non-empty string`); } if (mode === 'regex') { + if (!isSafeRegexPattern(r.pattern)) { + skipped.push(r.pattern); + return; + } try { rules.push({ mode: 'regex', pattern: r.pattern, reply: r.reply, regex: new RegExp(r.pattern, 'i') }); } catch { diff --git a/plugins.json b/plugins.json index 7ab18fd..979b38b 100644 --- a/plugins.json +++ b/plugins.json @@ -369,7 +369,7 @@ { "id": "faq-bot", "name": "FAQ / Auto-Reply Bot", - "version": "0.1.1", + "version": "0.1.2", "type": "extension", "status": "stable", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", @@ -389,7 +389,7 @@ "repoPath": "faq-bot", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/faq-bot", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/faq-bot-v0.1.1/faq-bot.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/faq-bot-v0.1.2/faq-bot.zip", "i18n": { "es": { "name": "Bot de Preguntas Frecuentes / Respuesta Automática",