diff --git a/README.md b/README.md index ae0a9a7..a1bde14 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ This repository provides: | [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.2 | 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.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 | +| [`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.3 | stable | | [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.2 | stable | diff --git a/group-translate/CHANGELOG.md b/group-translate/CHANGELOG.md index 1dd56d0..410ad99 100644 --- a/group-translate/CHANGELOG.md +++ b/group-translate/CHANGELOG.md @@ -8,6 +8,19 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.0.3] — 2026-06-23 + +### Fixed + +- Participant lookups now reject prototype keys (`__proto__`, `constructor`, `prototype`) and test + existence with `hasOwnProperty`, so a crafted participant/target id can no longer read or write + `Object.prototype`. +- Concurrent messages for the same group are serialized through a per-(session, chat) lock, closing a + load→mutate→save race that could duplicate the help announcement or drop a participant-language update. + The lock map self-evicts when a chat's queue drains. +- A LibreTranslate `/translate` response without a string `translatedText` now fails the call (counted by + the circuit breaker and excluded from the reply) instead of posting the literal text `undefined`. + ## [1.0.2] — 2026-06-23 ### Fixed diff --git a/group-translate/README.md b/group-translate/README.md index 42d3af6..5945c2e 100644 --- a/group-translate/README.md +++ b/group-translate/README.md @@ -13,7 +13,7 @@ | Field | Value | | ----- | ----- | | **Identifier** | `group-translate` | -| **Version** | 1.0.2 | +| **Version** | 1.0.3 | | **Released** | 2026-06-23 | | **Status** | stable | | **Author** | Yudhi Armyndharis | diff --git a/group-translate/core/translation.coordinator.test.ts b/group-translate/core/translation.coordinator.test.ts index 09e64bd..ad2ec7f 100644 --- a/group-translate/core/translation.coordinator.test.ts +++ b/group-translate/core/translation.coordinator.test.ts @@ -442,4 +442,37 @@ describe('TranslationCoordinator', () => { }), ); }); + + test('a sender wid of __proto__ does not pollute Object.prototype', async () => { + const { store, gateway, translator } = makeDeps(freshState({ active: true, announced: true })); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + await c.handleMessage('s', msg({ author: '__proto__', pushName: 'EVIL', body: 'hola amigo mio' })); + const leaked = (Object.prototype as Record).pushName; + delete (Object.prototype as Record).pushName; // cleanup regardless of assertion outcome + assert.equal(leaked, undefined, 'Object.prototype must not be polluted via a crafted participant wid'); + }); + + test('concurrent first messages for the same group announce only once', async () => { + let current: GroupState = freshState({ active: false, announced: false }); + const sends: string[] = []; + const store: ConfigStore = { + load: async () => { await Promise.resolve(); return JSON.parse(JSON.stringify(current)) as GroupState; }, + save: async (s: GroupState) => { await Promise.resolve(); current = JSON.parse(JSON.stringify(s)) as GroupState; }, + }; + const gateway: ChatGateway = { + sendText: async (_s: string, _c: string, text: string) => { await Promise.resolve(); sends.push(text); }, + sendCombinedReply: async () => {}, + getGroupAdmins: async () => [], + }; + const translator: Translator = { + detect: async () => ({ lang: 'en', confidence: 1 }), translate: async () => '', + languages: async () => ['en'], isHealthy: () => true, + }; + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + await Promise.all([ + c.handleMessage('s', msg({ id: 'm1', body: 'hello there' })), + c.handleMessage('s', msg({ id: 'm2', body: 'hello again' })), + ]); + assert.equal(sends.length, 1, 'the help announcement must be sent once, not duplicated by a load/save race'); + }); }); diff --git a/group-translate/core/translation.coordinator.ts b/group-translate/core/translation.coordinator.ts index 4a817e5..47a214b 100644 --- a/group-translate/core/translation.coordinator.ts +++ b/group-translate/core/translation.coordinator.ts @@ -22,6 +22,9 @@ export interface CoordinatorOptions { const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u; +/** Object keys that index the prototype chain rather than an own property; never valid as a wid. */ +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: () => {} }; /** @@ -37,6 +40,9 @@ function widEquals(a: string, b: string): boolean { } export class TranslationCoordinator { + /** Per (session,chat) promise chain serializing the load→mutate→save cycle. Self-evicts when drained. */ + private readonly locks = new Map>(); + constructor( private readonly translator: Translator, private readonly store: ConfigStore, @@ -47,7 +53,22 @@ export class TranslationCoordinator { async handleMessage(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> { if (!msg.isGroup || msg.fromMe || !msg.author) return { swallow: false }; + // Concurrent messages for the same group must not interleave load→mutate→save (lost updates / + // duplicate announcements). Chain each behind the previous for the same key; store a settled tail + // so one rejection can't wedge the chain, and evict the entry once the chain drains. + const key = `${sessionId}:${msg.chatId}`; + const prev = this.locks.get(key) ?? Promise.resolve(); + const run = prev.then(() => this.handleMessageLocked(sessionId, msg)); + const tail = run.catch(() => {}); + this.locks.set(key, tail); + try { + return await run; + } finally { + if (this.locks.get(key) === tail) this.locks.delete(key); + } + } + private async handleMessageLocked(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> { const state = await this.store.load(sessionId, msg.chatId); if (!state.announced) { @@ -232,7 +253,12 @@ export class TranslationCoordinator { } private ensureParticipant(state: GroupState, wid: string): ParticipantState { - if (!state.participants[wid]) { + if (UNSAFE_KEYS.has(wid)) { + // A real WhatsApp id never equals a prototype key; refuse to index the map by it so a crafted + // author/target can't read or write Object.prototype. Return a throwaway, non-persisted state. + return { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' }; + } + if (!Object.prototype.hasOwnProperty.call(state.participants, wid)) { state.participants[wid] = { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' }; } return state.participants[wid]; diff --git a/group-translate/libretranslate.client.test.ts b/group-translate/libretranslate.client.test.ts index ecbe07e..b4c942a 100644 --- a/group-translate/libretranslate.client.test.ts +++ b/group-translate/libretranslate.client.test.ts @@ -36,6 +36,12 @@ test('translate posts and returns translatedText on success', async () => { assert.equal(calls[0], 'http://lt:7001/translate'); // trailing slash trimmed }); +test('translate throws when the response lacks a translatedText string', async () => { + const { net } = fakeNet([async () => res({ json: async () => ({}) })]); // partial/empty body + const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net }); + await assert.rejects(c.translate('hi', 'en', 'es'), /translatedText/); +}); + test('detect returns the top result', async () => { const { net } = fakeNet([async () => res({ json: async () => [{ language: 'en', confidence: 0.9 }] })]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net }); diff --git a/group-translate/libretranslate.client.ts b/group-translate/libretranslate.client.ts index a363836..ded0d44 100644 --- a/group-translate/libretranslate.client.ts +++ b/group-translate/libretranslate.client.ts @@ -43,8 +43,13 @@ export class LibreTranslateClient implements Translator { async translate(text: string, source: string, target: string): Promise { const data = (await this.post('/translate', { q: text, source, target, format: 'text' })) as { - translatedText: string; + translatedText?: unknown; }; + if (typeof data?.translatedText !== 'string') { + // A partial/empty body must fail (counted by the circuit breaker, excluded from the reply) + // rather than become the literal string 'undefined' in the group. + throw new Error('LibreTranslate /translate returned no translatedText'); + } return data.translatedText; } diff --git a/group-translate/manifest.json b/group-translate/manifest.json index 6cdf1d9..a57ff48 100644 --- a/group-translate/manifest.json +++ b/group-translate/manifest.json @@ -1,7 +1,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.0.2", + "version": "1.0.3", "type": "extension", "main": "dist/index.js", "description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.", diff --git a/plugins.json b/plugins.json index 12fd96d..c240e0e 100644 --- a/plugins.json +++ b/plugins.json @@ -540,7 +540,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.0.2", + "version": "1.0.3", "type": "extension", "status": "stable", "description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.", @@ -560,7 +560,7 @@ "repoPath": "group-translate", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/group-translate", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.0.2/group-translate.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.0.3/group-translate.zip", "i18n": { "es": { "name": "Traducción Automática de Grupos",