Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
<!-- END PLUGIN CATALOG -->
Expand Down
12 changes: 12 additions & 0 deletions faq-bot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion faq-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions faq-bot/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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
});
5 changes: 3 additions & 2 deletions faq-bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ export function parseConfig(raw: Record<string, unknown>): {
}

/**
* 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<string, number>, 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;
Expand Down
2 changes: 1 addition & 1 deletion faq-bot/manifest.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
27 changes: 27 additions & 0 deletions faq-bot/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
57 changes: 56 additions & 1 deletion faq-bot/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
Loading