diff --git a/README.md b/README.md index e7736de..ed5d6b9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This repository provides: | [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.6 | 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.5 | stable | | [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable | +| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.0 | development | | [`typebot-connector`](./typebot-connector) | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.1.0 | beta | | [`voice-transcription`](./voice-transcription) | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.0.1 | beta | diff --git a/http-action/CHANGELOG.md b/http-action/CHANGELOG.md new file mode 100644 index 0000000..369da51 --- /dev/null +++ b/http-action/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to HTTP Action Bot are listed here. Versions follow [Semantic Versioning](https://semver.org/), +and the top entry's version must match `manifest.json`. + +## [0.1.0] — 2026-07-11 + +### Added +- Plugin scaffold: `manifest.json`, `IPlugin` lifecycle (`onEnable`, `healthCheck`), `message:received` hook with off-dispatch handling and inbound guards (`fromMe`, empty body, missing ids, group opt-in). +- Config layer (`config.ts`): fixed-https `baseUrl` (an `allowConfigHosts` key, required — no code-side default), server-relative path validation (rejects protocol-relative `//`, absolute URLs, fragments, control/null chars), dangerous-header blocklist (hop-by-hop + `x-forwarded-*`), CRLF injection rejection, `actions` JSON-string parsing, per-action structural validation, optional `bodyTemplate` for POST. +- Template engine (`url-template.ts`): prototype-safe dot-path access, `renderText` (replies), `renderPath` (URL-encoded segments), `renderJson` (JSON-safe body), bounded path depth + placeholder count. +- HTTP client (`client.ts`): fixed-origin URL build, encoded query, auth (none/bearer/apikey), rendered headers, `application/json` POST with re-parsed body, 256 KiB response cap, invalid-JSON guard. Mirrors `ctx.net.fetch(url, init)`. +- Matcher (`matcher.ts`): `exact`/`prefix` + case toggle + quoted-argument parsing, first-match-wins. +- Handler (`handleMessage`): match → fetch → status mapping (2xx/404/other) → render → `conversations.send` (quoted text reply), with default templates and a 4000-char reply cap. +- Reliability (`reliability.ts`): storage-backed idempotency (`claim`, fail-closed, 3-day TTL) + throttled `prune` so storage can't grow unbounded + in-memory per-chat `allowCooldown` (fail-open, LRU-capped). +- Test suites for every module (node:test); passes typecheck, `catalog:check`, build, and the loader contract. Order-status, stock-lookup, and ticket-creation use cases run end-to-end through the real message path. diff --git a/http-action/README.md b/http-action/README.md new file mode 100644 index 0000000..412e921 --- /dev/null +++ b/http-action/README.md @@ -0,0 +1,93 @@ +# HTTP Action Bot + +> Jalankan REST API aman dari command WhatsApp dan ubah respons JSON menjadi balasan chat. + + +| Field | Value | +| ----- | ----- | +| **Identifier** | `http-action` | +| **Version** | 0.1.0 | +| **Released** | 2026-07-11 | +| **Status** | development | +| **Author** | Yudhi Armyndharis | +| **License** | MIT | +| **Type** | `extension` | +| **Requires OpenWA** | ≥ null (tested null) | +| **Keywords** | api, rest, automation, connector, whatsapp, openwa | +| **Repository** | [OpenWA-plugins/http-action](https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action) | + + +## Features + +- Trigger `exact` / `prefix` dari body pesan. +- Method `GET` / `POST` JSON ke satu origin HTTPS tetap (`baseUrl`). +- Auth: none / Bearer / API-key header. +- Dot-path response mapping + reply template teks. +- Direct chat aktif default; group opt-in. Cooldown per chat + dedup message ID. +- **Safe by default:** origin tetap dari config (allowConfigHosts), path wajib relative, header berbahaya ditolak, secret dimasking. + +## What it does + +Saat pengguna mengetik command yang cocok (mis. `cek-order INV-001`), plugin memanggil endpoint API, membaca field dari respons JSON, dan membalas dengan teks yang dirender dari template. Satu request per pesan, satu balasan. + +## Setup + +1. Siapkan endpoint API HTTPS yang ingin dihubungkan. +2. Tentukan command + path + template untuk tiap aksi (lihat contoh config). +3. Isi `baseUrl` (wajib HTTPS) dan auth bila perlu. + +## Install + +Download ZIP dari [GitHub Release](https://github.com/rmyndharis/OpenWA-plugins/releases) (`http-action-vX.Y.Z`), lalu upload via dashboard OpenWA (`POST /plugins/install`). Plugin load dalam kondisi disabled; enable setelah config diisi. + +## Configuration + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `baseUrl` | string | — *(required)* | Origin API HTTPS. Host auto-added ke allowlist outbound. Wajib non-empty. | +| `authType` | enum | `none` | `none` · `bearer` · `apikey` | +| `authToken` | secret | — | Bearer token / API key (sesuai `authType`). Dimasking `***`. | +| `apiKeyHeader` | string | `X-API-Key` | Nama header untuk `authType=apikey`. | +| `respondInGroups` | boolean | `false` | Balas juga di group chat. | +| `timeoutMs` | number | `3000` | Timeout request (min 500). | +| `cooldownSeconds` | number | `3` | Cooldown per chat. | +| `actions` | textarea | — *(required)* | Array action sebagai JSON string (lihat contoh di configSchema). | + +```json +{ + "baseUrl": "https://erp.example.com", + "authType": "bearer", + "authToken": "***", + "actions": [ + { + "id": "check-order", + "match": { "type": "prefix", "value": "cek-order " }, + "request": { "method": "GET", "path": "/orders/{{args.0}}" }, + "replyTemplate": "Pesanan {{response.orderId}}: {{response.status}}\nResi: {{response.trackingNumber}}", + "notFoundTemplate": "Pesanan tidak ditemukan.", + "errorTemplate": "Layanan status pesanan bermasalah. Coba lagi nanti." + } + ] +} +``` + +## Compatibility + +> `minOpenWAVersion` / `testedOpenWAVersion` belum diisi — plugin masih development. Akan diisi setelah uji install + runtime pada versi OpenWA nyata. + +## Security + +- **Origin tetap:** `baseUrl` wajib HTTPS tanpa credential; host hanya dari config (allowConfigHosts), tidak pernah dari pesan. +- **Path aman:** wajib relative (`/...`), bukan protocol-relative (`//`) atau absolute URL; fragment & control char ditolak. +- **Header:** hop-by-hop + `x-forwarded-*` + CRLF ditolak (anti header injection). +- **No arbitrary code:** tidak ada `eval`/regex bebas/loop. Satu request per pesan. +- **Redirect lintas host:** di luar kendali plugin (tidak ada opsi `redirect` di `ctx.net.fetch`) — bergantung pada host proxy. Lihat catatan di spesifikasi internal. +- **Secret:** `authToken` ditandai `secret` di configSchema (dimasking `***`, di-preserve saat round-trip). + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md). + +## License + +MIT diff --git a/http-action/client.test.ts b/http-action/client.test.ts new file mode 100644 index 0000000..ae9a176 --- /dev/null +++ b/http-action/client.test.ts @@ -0,0 +1,142 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HttpActionClient, type FetchLike, type FetchResponse } from './client.ts'; +import { readConfig, type HttpAction, type HttpActionConfig } from './config.ts'; + +interface Captured { + url?: string; + init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }; +} + +// A recording fake mirroring PluginNetCapability.fetch(url, init). Returns a canned response, captures the call. +function fakeFetch(capture: Captured, res: { ok?: boolean; status?: number; body: string }): FetchLike { + return async (url: string, init?: Captured['init']): Promise => { + capture.url = url; + capture.init = init; + return { ok: res.ok ?? true, status: res.status ?? 200, statusText: 'OK', headers: {}, body: res.body }; + }; +} + +function cfgWith(over: Record = {}): { config: HttpActionConfig; action: HttpAction } { + const config = readConfig({ + baseUrl: 'https://api.example.com', + actions: JSON.stringify([{ + id: 'check', match: { type: 'prefix', value: 'cek ' }, + request: { method: 'GET', path: '/orders/{{args.0}}', query: { zone: '{{args.1}}' }, headers: { 'X-Trace': '{{message.id}}' } }, + replyTemplate: '{{response.status}}', + }, { + id: 'create', match: { type: 'prefix', value: 'buat ' }, + request: { method: 'POST', path: '/tickets', bodyTemplate: '{"desc":"{{args.0}}"}' }, + replyTemplate: '{{response.id}}', + }]), + ...over, + }); + return { config, action: config.actions[0] }; +} + +test('GET builds the URL from baseUrl + rendered (encoded) path', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['INV 001'] }); + assert.equal(cap.url, 'https://api.example.com/orders/INV%20001'); +}); + +test('GET has no body', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] }); + assert.equal(cap.init?.body, undefined); +}); + +test('GET appends an encoded query string from a templated value', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X', 'jakarta barat'] }); + assert.match(cap.url ?? '', /\?zone=jakarta%20barat$/); +}); + +test('action header values are rendered', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1' } }); + assert.equal(cap.init?.headers?.['X-Trace'], 'm1'); +}); + +test('a header value with CR/LF (via an attacker-controlled arg) is rejected, never sent', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + // action.request.headers = { 'X-Trace': '{{message.id}}' }; send a body whose id carries a newline + await assert.rejects( + () => new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1\nInjected: evil' } }), + /CR\/LF|header/i, + ); + assert.equal(cap.url, undefined); // fetch was never called +}); + +test('bearer auth adds Authorization: Bearer ', async () => { + const { config, action } = cfgWith({ authType: 'bearer', authToken: 'tok123' }); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] }); + assert.equal(cap.init?.headers?.['Authorization'], 'Bearer tok123'); +}); + +test('apikey auth adds the configured header name', async () => { + const { config, action } = cfgWith({ authType: 'apikey', authToken: 'key123', apiKeyHeader: 'X-Api-Key' }); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] }); + assert.equal(cap.init?.headers?.['X-Api-Key'], 'key123'); + assert.equal(cap.init?.headers?.['Authorization'], undefined); +}); + +test('none auth adds no auth header', async () => { + const { config, action } = cfgWith(); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] }); + assert.equal(cap.init?.headers?.['Authorization'], undefined); +}); + +test('POST sends a rendered, re-parsed JSON body with application/json content type', async () => { + const { config } = cfgWith(); + const action = config.actions[1]; // 'create' POST + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['internet mati'] }); + assert.equal(cap.init?.method, 'POST'); + assert.equal(cap.init?.headers?.['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(cap.init?.body ?? '{}'), { desc: 'internet mati' }); +}); + +test('parses a JSON response body into data', async () => { + const { config, action } = cfgWith(); + const client = new HttpActionClient(fakeFetch({}, { body: JSON.stringify({ status: 'shipped' }) }), config); + const out = await client.run(action, { args: ['X'] }); + assert.deepEqual(out.data, { status: 'shipped' }); + assert.equal(out.status, 200); +}); + +test('a response body over the 256 KiB cap is rejected', async () => { + const { config, action } = cfgWith(); + const big = 'x'.repeat(256 * 1024 + 1); + const client = new HttpActionClient(fakeFetch({}, { body: big }), config); + await assert.rejects(() => client.run(action, { args: ['X'] }), /too large|RESPONSE_TOO_LARGE/i); +}); + +test('a non-JSON body on a 2xx response throws (UPSTREAM_INVALID_JSON)', async () => { + const { config, action } = cfgWith(); + const client = new HttpActionClient(fakeFetch({}, { ok: true, status: 200, body: 'not json' }), config); + await assert.rejects(() => client.run(action, { args: ['X'] }), /invalid json|UPSTREAM_INVALID_JSON/i); +}); + +test('a non-ok status (404) is returned, not thrown, with the status', async () => { + const { config, action } = cfgWith(); + const client = new HttpActionClient(fakeFetch({}, { ok: false, status: 404, body: '{"error":"none"}' }), config); + const out = await client.run(action, { args: ['X'] }); + assert.equal(out.status, 404); + assert.deepEqual(out.data, { error: 'none' }); +}); + +test('timeoutMs from config is passed to fetch init', async () => { + const { config, action } = cfgWith({ timeoutMs: 2500 }); + const cap: Captured = {}; + await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] }); + assert.equal(cap.init?.timeoutMs, 2500); +}); diff --git a/http-action/client.ts b/http-action/client.ts new file mode 100644 index 0000000..9cf5b27 --- /dev/null +++ b/http-action/client.ts @@ -0,0 +1,97 @@ +// Fixed-origin HTTP client for HTTP Action Bot. Takes the host `ctx.net.fetch` (injected, so this tests +// without OpenWA) and a validated config; builds a safe request per action + template context and parses +// the JSON response. Pure modulo the injected fetch. +// +// The injected fetch matches the real PluginNetCapability.fetch(url, init) — URL is the first positional +// arg, init carries method/headers/body/timeoutMs (NOT a `url` field). Security: origin is fixed to +// cfg.baseUrl (config-validated https, allowConfigHosts); the path is rendered via renderPath (URL-encodes +// each arg segment so an arg can't add a segment or change origin); query values are encodeURIComponent'd; +// the POST body is JSON-escaped by renderJson and re-parsed before send. Response capped at 256 KiB. + +import type { HttpAction, HttpActionConfig } from './config.ts'; +import { renderPath, renderText, renderJson, renderHeader, type TemplateContext } from './url-template.ts'; + +const MAX_RESPONSE_BYTES = 256 * 1024; + +export interface FetchInit { + method?: string; + headers?: Record; + body?: string; + timeoutMs?: number; +} + +export interface FetchResponse { + ok: boolean; + status: number; + statusText?: string; + headers: Record; + body: string; +} + +/** Mirrors PluginNetCapability.fetch(url, init) so ctx.net.fetch.bind(ctx.net) drops straight in. */ +export type FetchLike = (url: string, init?: FetchInit) => Promise; + +export interface ActionResult { + status: number; + data: unknown; // parsed JSON, or undefined when a non-ok body wasn't JSON +} + +export class HttpActionClient { + constructor(private readonly fetch: FetchLike, private readonly cfg: HttpActionConfig) {} + + async run(action: HttpAction, ctx: TemplateContext): Promise { + const { url, init } = this.buildRequest(action, ctx); + const res = await this.fetch(url, init); + if (res.body.length > MAX_RESPONSE_BYTES) { + throw new Error('http-action: upstream response too large (RESPONSE_TOO_LARGE)'); + } + let data: unknown; + try { + data = res.body.length ? JSON.parse(res.body) : undefined; + } catch { + if (res.ok) throw new Error('http-action: upstream returned invalid JSON (UPSTREAM_INVALID_JSON)'); + data = undefined; // non-ok body may be a plaintext error; the handler maps via status template + } + return { status: res.status, data }; + } + + private buildRequest(action: HttpAction, ctx: TemplateContext): { url: string; init: FetchInit } { + const path = renderPath(action.request.path, ctx); + let url = this.cfg.baseUrl + path; + + if (action.request.query) { + const qs = Object.entries(action.request.query) + .map(([k, v]) => [k, renderText(v, ctx)] as const) + .filter(([, val]) => val !== '') // omit params whose value renders empty (e.g. a missing arg) + .map(([k, val]) => `${encodeURIComponent(k)}=${encodeURIComponent(val)}`) + .join('&'); + if (qs) url += `?${qs}`; + } + + const headers: Record = {}; + if (action.request.headers) { + for (const [k, v] of Object.entries(action.request.headers)) headers[k] = renderHeader(v, ctx); + } + + // Auth — authToken is a configSchema secret; never logged by the caller. + if (this.cfg.authType === 'bearer') headers['Authorization'] = `Bearer ${this.cfg.authToken}`; + else if (this.cfg.authType === 'apikey') headers[this.cfg.apiKeyHeader] = this.cfg.authToken ?? ''; + + const init: FetchInit = { method: action.request.method, headers, timeoutMs: this.cfg.timeoutMs }; + + if (action.request.method === 'POST') { + headers['Content-Type'] = 'application/json'; + if (action.request.bodyTemplate) { + const rendered = renderJson(action.request.bodyTemplate, ctx); + try { + JSON.parse(rendered); // validate before send (anti JSON injection via an arg) + } catch { + throw new Error('http-action: rendered request body is not valid JSON'); + } + init.body = rendered; + } + } + + return { url, init }; + } +} diff --git a/http-action/config.test.ts b/http-action/config.test.ts new file mode 100644 index 0000000..2038ebd --- /dev/null +++ b/http-action/config.test.ts @@ -0,0 +1,197 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readConfig, isDangerousHeader, isAllowedMethod, validatePath } from './config.ts'; + +const validActions = JSON.stringify([ + { + id: 'check-order', + match: { type: 'prefix', value: 'cek-order ' }, + request: { method: 'GET', path: '/orders/{{args.0}}' }, + replyTemplate: 'Pesanan {{response.orderId}}: {{response.status}}', + }, +]); + +const base = (over: Record = {}): Record => ({ + baseUrl: 'https://erp.example.com', + actions: validActions, + ...over, +}); + +test('happy path: parses a valid config and applies defaults', () => { + const cfg = readConfig(base()); + assert.equal(cfg.baseUrl, 'https://erp.example.com'); + assert.equal(cfg.authType, 'none'); + assert.equal(cfg.respondInGroups, false); + assert.equal(cfg.timeoutMs, 3000); + assert.equal(cfg.cooldownSeconds, 3); + assert.equal(cfg.apiKeyHeader, 'X-API-Key'); + assert.equal(cfg.actions.length, 1); + assert.equal(cfg.actions[0].match.caseSensitive, false); +}); + +test('baseUrl: trailing slash is stripped', () => { + assert.equal(readConfig(base({ baseUrl: 'https://erp.example.com/' })).baseUrl, 'https://erp.example.com'); +}); + +test('baseUrl: required (empty rejected — allowConfigHosts, no code default)', () => { + assert.throws(() => readConfig(base({ baseUrl: '' })), /baseUrl: is required/); + assert.throws(() => readConfig(base({ baseUrl: ' ' })), /baseUrl: is required/); +}); + +test('baseUrl: must be a URL', () => { + assert.throws(() => readConfig(base({ baseUrl: 'not a url' })), /baseUrl: must be a valid URL/); +}); + +test('baseUrl: must be https', () => { + assert.throws(() => readConfig(base({ baseUrl: 'http://erp.example.com' })), /baseUrl: must be https/); +}); + +test('baseUrl: no embedded credentials', () => { + assert.throws(() => readConfig(base({ baseUrl: 'https://user:pass@erp.example.com' })), /embedded credentials/); +}); + +test('baseUrl: no fragment', () => { + assert.throws(() => readConfig(base({ baseUrl: 'https://erp.example.com#x' })), /fragment/); +}); + +test('baseUrl: no query string (origin/path only)', () => { + assert.throws(() => readConfig(base({ baseUrl: 'https://erp.example.com/api?key=ABC' })), /query string/); +}); + +test('apiKeyHeader: reserved/dangerous header name rejected', () => { + assert.throws(() => readConfig(base({ authType: 'apikey', authToken: 'k', apiKeyHeader: 'Host' })), /reserved\/dangerous/); + assert.throws(() => readConfig(base({ authType: 'apikey', authToken: 'k', apiKeyHeader: 'X-Forwarded-For' })), /reserved\/dangerous/); +}); + +test('auth: bearer/apikey require authToken', () => { + assert.throws(() => readConfig(base({ authType: 'bearer' })), /authToken: is required/); + assert.throws(() => readConfig(base({ authType: 'apikey' })), /authToken: is required/); + const cfg = readConfig(base({ authType: 'bearer', authToken: 'tok' })); + assert.equal(cfg.authType, 'bearer'); + assert.equal(cfg.authToken, 'tok'); +}); + +test('actions: required (empty JSON string rejected)', () => { + assert.throws(() => readConfig(base({ actions: '' })), /actions: is required/); + assert.throws(() => readConfig(base({ actions: ' ' })), /actions: is required/); +}); + +test('actions: malformed JSON rejected', () => { + assert.throws(() => readConfig(base({ actions: '{not json' })), /actions: JSON parse failed/); +}); + +test('actions: must be an array', () => { + assert.throws(() => readConfig(base({ actions: JSON.stringify({ id: 'x' }) })), /actions: must be a JSON array/); +}); + +test('actions: at least one', () => { + assert.throws(() => readConfig(base({ actions: '[]' })), /at least one action/); +}); + +test('actions: at most 25', () => { + const many = Array.from({ length: 26 }, (_, i) => ({ + id: `a${i}`, match: { type: 'exact', value: `v${i}` }, + request: { method: 'GET', path: '/x' }, replyTemplate: 'r', + })); + assert.throws(() => readConfig(base({ actions: JSON.stringify(many) })), /at most 25/); +}); + +test('actions: accepts a pre-parsed array (not only a JSON string)', () => { + const cfg = readConfig(base({ actions: [{ id: 'x', match: { type: 'exact', value: 'hi' }, request: { method: 'GET', path: '/x' }, replyTemplate: 'r' }] })); + assert.equal(cfg.actions[0].id, 'x'); +}); + +test('action.id: required + safe charset', () => { + const bad = (id: string) => [{ id, match: { type: 'exact', value: 'v' }, request: { method: 'GET', path: '/x' }, replyTemplate: 'r' }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(bad('')) })), /id is required/); + assert.throws(() => readConfig(base({ actions: JSON.stringify(bad('a b')) })), /id may only contain/); + assert.throws(() => readConfig(base({ actions: JSON.stringify(bad('a/b')) })), /id may only contain/); +}); + +test('match: type enum + non-empty value', () => { + const mk = (m: unknown) => [{ id: 'x', match: m, request: { method: 'GET', path: '/x' }, replyTemplate: 'r' }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk({ type: 'regex', value: 'v' })) })), /type must be/); + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk({ type: 'exact', value: '' })) })), /value is required/); +}); + +test('method: GET/POST only', () => { + const mk = (method: string) => [{ id: 'x', match: { type: 'exact', value: 'v' }, request: { method, path: '/x' }, replyTemplate: 'r' }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk('DELETE')) })), /method must be/); +}); + +test('validatePath: protocol-relative // rejected', () => { + assert.throws(() => validatePath('//evil.example/path', 'p'), /protocol-relative/); +}); + +test('validatePath: absolute URL rejected (no leading /)', () => { + assert.throws(() => validatePath('https://evil.example/path', 'p'), /must be relative and start with \//); +}); + +test('validatePath: fragment rejected', () => { + assert.throws(() => validatePath('/orders/x#frag', 'p'), /fragment/); +}); + +test('validatePath: control + null chars rejected', () => { + assert.throws(() => validatePath('/orders/\tx', 'p'), /control\/null/); + assert.throws(() => validatePath('/orders/\rx', 'p'), /control\/null/); +}); + +test('validatePath: a normal relative path with template placeholder is accepted', () => { + assert.equal(validatePath('/orders/{{args.0}}', 'p'), '/orders/{{args.0}}'); +}); + +test('headers: dangerous headers rejected (host, x-forwarded-*)', () => { + const mk = (headers: Record) => [{ + id: 'x', match: { type: 'exact', value: 'v' }, + request: { method: 'GET', path: '/x', headers }, replyTemplate: 'r', + }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk({ Host: 'evil' })) })), /reserved\/dangerous/); + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk({ 'X-Forwarded-For': '1.2.3.4' })) })), /reserved\/dangerous/); +}); + +test('headers: CRLF rejected (header injection)', () => { + const mk = (headers: Record) => [{ + id: 'x', match: { type: 'exact', value: 'v' }, + request: { method: 'GET', path: '/x', headers }, replyTemplate: 'r', + }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(mk({ 'X-Custom': 'a\r\nInjected: b' })) })), /CR\/LF/); +}); + +test('replyTemplate: required', () => { + const bad = [{ id: 'x', match: { type: 'exact', value: 'v' }, request: { method: 'GET', path: '/x' } }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(bad) })), /replyTemplate is required/); +}); + +test('timeoutMs: below the 500 floor falls back to default 3000', () => { + assert.equal(readConfig(base({ timeoutMs: 100 })).timeoutMs, 3000); + assert.equal(readConfig(base({ timeoutMs: 'oops' })).timeoutMs, 3000); +}); + +test('request.bodyTemplate: optional, accepted as a JSON string', () => { + const actions = [{ + id: 'x', match: { type: 'exact', value: 'v' }, + request: { method: 'POST', path: '/x', bodyTemplate: '{"q":"{{args.0}}"}' }, replyTemplate: 'r', + }]; + const cfg = readConfig(base({ actions: JSON.stringify(actions) })); + assert.equal(cfg.actions[0].request.bodyTemplate, '{"q":"{{args.0}}"}'); +}); + +test('request.bodyTemplate: non-string rejected', () => { + const actions = [{ + id: 'x', match: { type: 'exact', value: 'v' }, + request: { method: 'POST', path: '/x', bodyTemplate: 123 }, replyTemplate: 'r', + }]; + assert.throws(() => readConfig(base({ actions: JSON.stringify(actions) })), /bodyTemplate/); +}); + +test('request.bodyTemplate: omitted → undefined (GET still valid)', () => { + const cfg = readConfig(base()); // default validActions has no bodyTemplate + assert.equal(cfg.actions[0].request.bodyTemplate, undefined); +}); + +test('isDangerousHeader + isAllowedMethod exports behave', () => { + assert.equal(isDangerousHeader('Host'), true); + assert.equal(isDangerousHeader('Authorization'), false); // plugin may set its own auth header + assert.equal(isAllowedMethod('GET'), true); + assert.equal(isAllowedMethod('PUT'), false); +}); diff --git a/http-action/config.ts b/http-action/config.ts new file mode 100644 index 0000000..413703b --- /dev/null +++ b/http-action/config.ts @@ -0,0 +1,200 @@ +// Config parsing + validation for HTTP Action Bot. Pure (no ctx) so it tests without OpenWA. +// +// Security-critical: WhatsApp message text is attacker-controlled, so every value the plugin later +// interpolates into an outbound request is bounded HERE — fixed https origin (an allowConfigHosts key, +// so no code-side default: the net gate resolves the allowed host from RAW ctx.config), relative-only +// path (no protocol-relative //, no absolute URL, no fragment, no control chars), and a header blocklist +// (hop-by-hop + forwarding headers + CRLF). See roadmap §4.5 + §1.2 #3. + +export type AuthType = 'none' | 'bearer' | 'apikey'; +export type MatchType = 'exact' | 'prefix'; +export type Method = 'GET' | 'POST'; + +export interface ActionMatch { + type: MatchType; + value: string; + caseSensitive: boolean; +} + +export interface ActionRequest { + method: Method; + path: string; + query?: Record; + headers?: Record; + bodyTemplate?: string; +} + +export interface HttpAction { + id: string; + match: ActionMatch; + request: ActionRequest; + replyTemplate: string; + notFoundTemplate?: string; + errorTemplate?: string; +} + +export interface HttpActionConfig { + baseUrl: string; // https origin, no trailing slash, no credentials, no fragment + authType: AuthType; + authToken?: string; // bearer token or apikey value (configSchema secret) + apiKeyHeader: string; // header name for authType=apikey + respondInGroups: boolean; + timeoutMs: number; + cooldownSeconds: number; + actions: HttpAction[]; +} + +const MAX_ACTIONS = 25; + +// Reserved/semantics-bearing headers config must not set (host smuggling, hop-by-hop, forwarding chain). +const DANGEROUS_HEADERS = new Set([ + 'host', 'connection', 'content-length', 'transfer-encoding', 'te', 'trailer', + 'upgrade', 'proxy-authorization', 'proxy-authenticate', 'expect', + 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', + 'x-forwarded-port', 'x-forwarded-server', 'x-real-ip', +]); + +// Action ids become log labels — constrain to a safe charset so they can't break structured logs. +const ACTION_ID_RE = /^[A-Za-z0-9_-]+$/; + +function fail(field: string, why: string): never { + throw new Error(`http-action: invalid config — ${field}: ${why}`); +} + +export function isDangerousHeader(name: string): boolean { + return DANGEROUS_HEADERS.has(name.toLowerCase().trim()); +} + +export function isAllowedMethod(m: unknown): m is Method { + return m === 'GET' || m === 'POST'; +} + +// path must be server-relative only: leading single /, not protocol-relative (//), not an absolute URL +// (no leading /), no fragment, no control/null chars. +export function validatePath(path: unknown, field: string): string { + if (typeof path !== 'string' || path.length === 0) fail(field, 'path is required'); + if (!path.startsWith('/')) fail(field, 'path must be relative and start with /'); + if (path.startsWith('//')) fail(field, 'path must not be protocol-relative (//)'); + if (path.includes('#')) fail(field, 'path must not contain a fragment (#)'); + if (/[\r\n\t\0]/.test(path)) fail(field, 'path must not contain control/null characters'); + return path; +} + +function validateStringMap(v: unknown, field: string, isHeaders: boolean): Record { + const out: Record = {}; + if (v === undefined || v === null) return out; + if (typeof v !== 'object') fail(field, 'must be an object'); + for (const [k, val] of Object.entries(v as Record)) { + const ks = String(k); + const vs = String(val); + if (/[\r\n]/.test(ks) || /[\r\n]/.test(vs)) fail(field, 'entry contains CR/LF (header injection)'); + if (isHeaders && isDangerousHeader(ks)) fail(`${field}.${ks}`, 'reserved/dangerous header is not allowed'); + out[ks] = vs; + } + return out; +} + +function validateAction(a: unknown, idx: number): HttpAction { + const field = `actions[${idx}]`; + if (typeof a !== 'object' || a === null) fail(field, 'action must be an object'); + const o = a as Record; + + const id = String(o.id ?? '').trim(); + if (!id) fail(`${field}.id`, 'id is required'); + if (!ACTION_ID_RE.test(id)) fail(`${field}.id`, 'id may only contain A-Z a-z 0-9 _ -'); + + const m = o.match; + if (typeof m !== 'object' || m === null) fail(`${field}.match`, 'match is required'); + const mm = m as Record; + if (mm.type !== 'exact' && mm.type !== 'prefix') fail(`${field}.match.type`, "type must be 'exact' or 'prefix'"); + const matchValue = typeof mm.value === 'string' ? mm.value : ''; + if (matchValue.length === 0) fail(`${field}.match.value`, 'value is required and must be non-empty'); + + const r = o.request; + if (typeof r !== 'object' || r === null) fail(`${field}.request`, 'request is required'); + const rr = r as Record; + if (!isAllowedMethod(rr.method)) fail(`${field}.request.method`, "method must be 'GET' or 'POST'"); + const path = validatePath(rr.path, `${field}.request.path`); + const headers = validateStringMap(rr.headers, `${field}.request.headers`, true); + const query = validateStringMap(rr.query, `${field}.request.query`, false); + if (rr.bodyTemplate !== undefined && typeof rr.bodyTemplate !== 'string') { + fail(`${field}.request.bodyTemplate`, 'must be a string (a JSON template)'); + } + const bodyTemplate = typeof rr.bodyTemplate === 'string' ? rr.bodyTemplate : undefined; + + const replyTemplate = typeof o.replyTemplate === 'string' ? o.replyTemplate : ''; + if (replyTemplate.length === 0) fail(`${field}.replyTemplate`, 'replyTemplate is required'); + + return { + id, + match: { type: mm.type, value: matchValue, caseSensitive: mm.caseSensitive === true }, + request: { + method: rr.method, + path, + query: Object.keys(query).length ? query : undefined, + headers: Object.keys(headers).length ? headers : undefined, + bodyTemplate, + }, + replyTemplate, + notFoundTemplate: typeof o.notFoundTemplate === 'string' && o.notFoundTemplate ? o.notFoundTemplate : undefined, + errorTemplate: typeof o.errorTemplate === 'string' && o.errorTemplate ? o.errorTemplate : undefined, + }; +} + +export function readConfig(raw: Record): HttpActionConfig { + // baseUrl is an allowConfigHosts key: the host net gate resolves the allowed host from RAW ctx.config, + // so a code-side default is invisible to the gate and every fetch silently no-ops. Require it. + const baseUrlRaw = String(raw.baseUrl ?? '').trim(); + if (!baseUrlRaw) fail('baseUrl', 'is required (allowConfigHosts key — no code default)'); + let origin: URL; + try { + origin = new URL(baseUrlRaw); + } catch { + fail('baseUrl', 'must be a valid URL'); + } + if (origin.protocol !== 'https:') fail('baseUrl', 'must be https'); + if (origin.username || origin.password) fail('baseUrl', 'must not contain embedded credentials'); + if (origin.hash) fail('baseUrl', 'must not contain a fragment'); + if (origin.search) fail('baseUrl', 'must not contain a query string (origin/path only)'); + const baseUrl = baseUrlRaw.replace(/\/+$/, ''); + + const authType: AuthType = raw.authType === 'bearer' || raw.authType === 'apikey' ? raw.authType : 'none'; + const authToken = raw.authToken ? String(raw.authToken) : undefined; + if (authType !== 'none' && !authToken) fail('authToken', `is required when authType='${authType}'`); + + const apiKeyHeader = String(raw.apiKeyHeader ?? 'X-API-Key').trim() || 'X-API-Key'; + if (/[\r\n]/.test(apiKeyHeader)) fail('apiKeyHeader', 'must not contain CR/LF'); + if (isDangerousHeader(apiKeyHeader)) fail('apiKeyHeader', 'must not be a reserved/dangerous header (host/connection/x-forwarded-*/…)'); + + const timeoutNum = Number(raw.timeoutMs); + const timeoutMs = Number.isFinite(timeoutNum) && timeoutNum >= 500 ? timeoutNum : 3000; + const cooldownNum = Number(raw.cooldownSeconds); + const cooldownSeconds = Number.isFinite(cooldownNum) && cooldownNum >= 0 ? cooldownNum : 3; + + // actions arrives as a JSON string (configSchema type:textarea) or a pre-parsed array. + let actionsRaw: unknown = raw.actions; + if (typeof actionsRaw === 'string') { + const trimmed = actionsRaw.trim(); + if (!trimmed) fail('actions', 'is required (a JSON array)'); + try { + actionsRaw = JSON.parse(trimmed); + } catch (e) { + fail('actions', `JSON parse failed: ${(e as Error).message}`); + } + } + if (!Array.isArray(actionsRaw)) fail('actions', 'must be a JSON array'); + if (actionsRaw.length < 1) fail('actions', 'must contain at least one action'); + if (actionsRaw.length > MAX_ACTIONS) fail('actions', `must contain at most ${MAX_ACTIONS} actions`); + const actions = actionsRaw.map((a, i) => validateAction(a, i)); + + return { + baseUrl, + authType, + authToken, + apiKeyHeader, + respondInGroups: raw.respondInGroups === true, + timeoutMs, + cooldownSeconds, + actions, + }; +} diff --git a/http-action/index.test.ts b/http-action/index.test.ts new file mode 100644 index 0000000..49b0a14 --- /dev/null +++ b/http-action/index.test.ts @@ -0,0 +1,194 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { handleMessage, type HandleDeps } from './index.ts'; +import { readConfig } from './config.ts'; +import { hasSeen, type StorageLike } from './reliability.ts'; +import type { IncomingMessage } from '../types/openwa'; + +function cfgWith(over: Record = {}) { + return readConfig({ + baseUrl: 'https://api.example.com', + actions: JSON.stringify([{ + id: 'check', match: { type: 'prefix', value: 'cek ' }, + request: { method: 'GET', path: '/orders/{{args.0}}' }, + replyTemplate: 'Status: {{response.status}}', + notFoundTemplate: 'NF', + errorTemplate: 'ERR', + }]), + ...over, + }); +} + +function fakeStore(): StorageLike & { m: Map } { + const m = new Map(); + return { + m, + get: async (k: string): Promise => (m.has(k) ? (m.get(k) as T) : null), + set: async (k: string, v: unknown) => { m.set(k, v); }, + delete: async (k: string) => { m.delete(k); }, + list: async (prefix?: string) => [...m.keys()].filter((k) => (prefix ? k.startsWith(prefix) : true)), + }; +} + +const msg = (body: string, id = 'm1'): IncomingMessage => ({ + id, from: '62@s.whatsapp.net', to: 'bot', chatId: 'c1', + body, type: 'text', timestamp: 0, fromMe: false, isGroup: false, +}) as IncomingMessage; + +interface Opts { body?: string; status?: number; ok?: boolean; reject?: boolean; now?: () => number } + +function makeDeps(o: Opts = {}) { + const sendCalls: { env: unknown }[] = []; + const errors: unknown[] = []; + const d: HandleDeps = { + cfg: cfgWith(), + storage: fakeStore(), + cooldown: new Map(), + now: o.now ?? (() => 1000), + fetch: async () => { + if (o.reject) throw new Error('network'); + return { ok: o.ok ?? true, status: o.status ?? 200, statusText: 'OK', headers: {}, body: o.body ?? '{}' }; + }, + conversations: { send: async (env: unknown) => { sendCalls.push({ env }); } }, + logger: { log() {}, warn() {}, error: (m: string, e?: unknown) => errors.push([m, e]) }, + }; + return { d, sendCalls, errors }; +} + +test('2xx: renders the reply template from the JSON response and sends it quoting the inbound', async () => { + const { d, sendCalls } = makeDeps({ body: JSON.stringify({ status: 'shipped' }) }); + await handleMessage(d, 's1', msg('cek INV-001')); + assert.equal(sendCalls.length, 1); + assert.deepEqual(sendCalls[0].env, { + sessionId: 's1', chatId: 'c1', type: 'text', text: 'Status: shipped', replyTo: 'm1', + }); +}); + +test('404: sends the notFoundTemplate', async () => { + const { d, sendCalls } = makeDeps({ ok: false, status: 404, body: '{}' }); + await handleMessage(d, 's1', msg('cek X')); + assert.equal((sendCalls[0].env as { text: string }).text, 'NF'); +}); + +test('500: sends the errorTemplate', async () => { + const { d, sendCalls } = makeDeps({ ok: false, status: 500, body: '{}' }); + await handleMessage(d, 's1', msg('cek X')); + assert.equal((sendCalls[0].env as { text: string }).text, 'ERR'); +}); + +test('fetch failure: sends the errorTemplate and logs an error', async () => { + const { d, sendCalls, errors } = makeDeps({ reject: true }); + await handleMessage(d, 's1', msg('cek X')); + assert.equal((sendCalls[0].env as { text: string }).text, 'ERR'); + assert.ok(errors.length >= 1); // routed through logger.error (not warn) so the Error context is kept +}); + +test('no trigger match: nothing is sent (silent)', async () => { + const { d, sendCalls } = makeDeps({ body: '{}' }); + await handleMessage(d, 's1', msg('hello world')); + assert.equal(sendCalls.length, 0); +}); + +test('a reply over the cap is truncated', async () => { + const cfg = readConfig({ + baseUrl: 'https://api.example.com', + actions: JSON.stringify([{ + id: 'check', match: { type: 'prefix', value: 'cek ' }, + request: { method: 'GET', path: '/x' }, replyTemplate: '{{response}}', + }]), + }); + const sendCalls: { env: unknown }[] = []; + await handleMessage({ + cfg, storage: fakeStore(), cooldown: new Map(), now: () => 1000, + fetch: async () => ({ ok: true, status: 200, statusText: 'OK', headers: {}, body: JSON.stringify('x'.repeat(6000)) }), + conversations: { send: async (env: unknown) => { sendCalls.push({ env }); } }, + logger: { log() {}, warn() {}, error() {} }, + }, 's1', msg('cek X')); + const text = (sendCalls[0].env as { text: string }).text; + assert.ok(text.length <= 4000, `expected <= 4000, got ${text.length}`); + assert.ok(text.endsWith('…')); +}); + +test('missing notFoundTemplate falls back to a generic default', async () => { + const cfg = readConfig({ + baseUrl: 'https://api.example.com', + actions: JSON.stringify([{ + id: 'check', match: { type: 'prefix', value: 'cek ' }, + request: { method: 'GET', path: '/x' }, replyTemplate: 'ok {{response.status}}', + }]), + }); + const sendCalls: { env: unknown }[] = []; + await handleMessage({ + cfg, storage: fakeStore(), cooldown: new Map(), now: () => 1000, + fetch: async () => ({ ok: false, status: 404, statusText: 'NF', headers: {}, body: '{}' }), + conversations: { send: async (env: unknown) => { sendCalls.push({ env }); } }, + logger: { log() {}, warn() {}, error() {} }, + }, 's1', msg('cek X')); + const text = (sendCalls[0].env as { text: string }).text; + assert.ok(!text.includes('{{'), 'default notFound should have no unresolved placeholder'); + assert.ok(text.length > 0); +}); + +// ---- #8 reliability gates ---- + +test('dedup: a redelivered message id is not processed twice', async () => { + const { d, sendCalls } = makeDeps({ body: JSON.stringify({ status: 'shipped' }) }); + await handleMessage(d, 's1', msg('cek X', 'm1')); + await handleMessage(d, 's1', msg('cek X', 'm1')); // same id → redelivery + assert.equal(sendCalls.length, 1); +}); + +test('cooldown: a second message from the same chat within the window is dropped', async () => { + const { d, sendCalls } = makeDeps({ body: '{}' }); + await handleMessage(d, 's1', msg('cek X', 'm1')); // different id below, same chat + await handleMessage(d, 's1', msg('cek X', 'm2')); + assert.equal(sendCalls.length, 1); // dedup passes (distinct ids), cooldown blocks +}); + +test('cooldown: a message after the window is allowed', async () => { + let now = 1000; + const { d, sendCalls } = makeDeps({ body: '{}', now: () => now }); + await handleMessage(d, 's1', msg('cek X', 'm1')); + now += cfgWith().cooldownSeconds * 1000 + 1; // past the 3s window + await handleMessage(d, 's1', msg('cek X', 'm2')); + assert.equal(sendCalls.length, 2); +}); + +test('dedup fail-closed: a storage error drops the message (no reply, no double-fire risk)', async () => { + const sendCalls: { env: unknown }[] = []; + const broken = { + get: async () => { throw new Error('storage down'); }, + set: async () => {}, delete: async () => {}, list: async () => [] as string[], + }; + await handleMessage({ + cfg: cfgWith(), storage: broken, cooldown: new Map(), now: () => 1000, + fetch: async () => ({ ok: true, status: 200, statusText: 'OK', headers: {}, body: '{}' }), + conversations: { send: async (env: unknown) => { sendCalls.push({ env }); } }, + logger: { log() {}, warn() {}, error() {} }, + }, 's1', msg('cek X', 'm1')); + assert.equal(sendCalls.length, 0); // dropped, not processed +}); + +test('send failure leaves the message un-marked: a redelivery after the cooldown window retries', async () => { + let now = 1000; + let sendAttempts = 0; + const sendCalls: { env: unknown }[] = []; + const send = async (env: unknown): Promise => { + sendAttempts++; + if (sendAttempts === 1) throw new Error('transient WA failure'); + sendCalls.push({ env }); + }; + const storage = fakeStore(); + const d: HandleDeps = { + cfg: cfgWith(), storage, cooldown: new Map(), now: () => now, + fetch: async () => ({ ok: true, status: 200, statusText: 'OK', headers: {}, body: JSON.stringify({ status: 'ok' }) }), + conversations: { send }, logger: { log() {}, warn() {}, error() {} }, + }; + await handleMessage(d, 's1', msg('cek X', 'm1')).catch(() => {}); // send rejects → no mark + assert.equal(sendCalls.length, 0); // first send failed → no reply + assert.equal(await hasSeen(storage, 's1', 'm1'), false); // un-marked → redelivery can retry + now += cfgWith().cooldownSeconds * 1000 + 1; // past the cooldown window + await handleMessage(d, 's1', msg('cek X', 'm1')); // redelivery retries + assert.equal(sendCalls.length, 1); // retried send succeeded + assert.equal(await hasSeen(storage, 's1', 'm1'), true); // marked only after the successful send +}); diff --git a/http-action/index.ts b/http-action/index.ts new file mode 100644 index 0000000..51b54be --- /dev/null +++ b/http-action/index.ts @@ -0,0 +1,161 @@ +import type { + IPlugin, PluginContext, HookContext, HookResult, IncomingMessage, ConversationSendEnvelope, +} from '../types/openwa'; +import { readConfig, type HttpActionConfig } from './config.ts'; +import { matchAction } from './matcher.ts'; +import { renderText, type TemplateContext } from './url-template.ts'; +import { HttpActionClient, type FetchLike } from './client.ts'; +import { hasSeen, markSeen, prune, allowCooldown, type StorageLike, DEDUP_TTL_MS, PRUNE_INTERVAL_MS } from './reliability.ts'; + +const PLUGIN = 'http-action'; +const REPLY_MAX = 4000; +const DEFAULT_NOT_FOUND = 'Data tidak ditemukan.'; +const DEFAULT_ERROR = 'Layanan sedang bermasalah. Coba lagi nanti.'; + +/** Dependencies handleMessage needs, injected so the per-message logic tests without OpenWA. */ +export interface HandleDeps { + cfg: HttpActionConfig; + fetch: FetchLike; + conversations: { send(env: ConversationSendEnvelope): Promise }; + storage: StorageLike; + cooldown: Map; + now: () => number; + logger: { log(m: string): void; warn(m: string, e?: unknown): void; error(m: string, e?: unknown): void }; +} + +/** Strip C0 control chars (except \n, \t) so an attacker-influenced upstream value can't smuggle them into the reply. */ +function sanitize(s: string): string { + return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); +} + +/** Truncate to REPLY_MAX code units without splitting a UTF-16 surrogate pair. */ +function truncate(s: string): string { + if (s.length <= REPLY_MAX) return s; + let cut = REPLY_MAX - 1; + if (cut > 0) { + const code = s.charCodeAt(cut - 1); + if (code >= 0xd800 && code <= 0xdbff) cut -= 1; // last included char is a high surrogate → back off + } + return `${s.slice(0, cut)}…`; +} + +function buildCtx(msg: IncomingMessage, sessionId: string, args: string[], response?: unknown): TemplateContext { + return { + args, + message: { id: msg.id, body: msg.body }, + chat: { id: msg.chatId }, + sender: { id: msg.from, phone: msg.senderPhone ?? '', name: msg.contact?.pushName ?? msg.contact?.name ?? '' }, + session: { id: sessionId }, + response, + }; +} + +/** + * Per-message work: match → dedup CHECK (fail-closed) → cooldown (fail-open) → fetch → map status → + * render → send → mark seen. The dedup MARK is written only after a successful send, so a transient send + * failure retries on redelivery instead of being silently dropped (mirrors chatwoot's hasSeen/markSeen). + */ +export async function handleMessage(deps: HandleDeps, sessionId: string, msg: IncomingMessage): Promise { + const hit = matchAction(deps.cfg.actions, msg.body); + if (!hit) return; // no trigger matched → silent + + // Dedup CHECK (read-only, fail-closed): drop a redelivery of an already-processed message id. + if (await hasSeen(deps.storage, sessionId, msg.id)) return; + // Best-effort prune of expired markers (throttled hourly); never blocks the reply. + void prune(deps.storage, deps.now(), DEDUP_TTL_MS, PRUNE_INTERVAL_MS).catch((e) => + deps.logger.error(`${PLUGIN}: prune failed`, e), + ); + // Cooldown (fail-open): one reply per chat per window. Checked before the mark so a blocked message + // consumes nothing and a later message (after the window) still goes through. + const cooldownMs = Math.max(0, deps.cfg.cooldownSeconds) * 1000; + if (!allowCooldown(deps.cooldown, `${sessionId}:${msg.chatId}`, deps.now(), cooldownMs)) return; + + const { action, args } = hit; + const client = new HttpActionClient(deps.fetch, deps.cfg); + const ctxWith = (response?: unknown): TemplateContext => buildCtx(msg, sessionId, args, response); + + let text: string; + try { + const out = await client.run(action, ctxWith()); + if (out.status === 404) { + text = renderText(action.notFoundTemplate ?? DEFAULT_NOT_FOUND, ctxWith(out.data)); + } else if (out.status >= 200 && out.status < 300) { + text = renderText(action.replyTemplate, ctxWith(out.data)); + } else { + text = renderText(action.errorTemplate ?? DEFAULT_ERROR, ctxWith(out.data)); + } + } catch (e) { + text = renderText(action.errorTemplate ?? DEFAULT_ERROR, ctxWith()); + deps.logger.error(`${PLUGIN}: request failed`, e); + } + + // replyTo is safe here — replies are always text (media is a non-goal). See §1.4. + // A send rejection propagates out of handleMessage (to the hook's .catch) BEFORE markSeen runs, so the + // message stays un-marked and a redelivery retries — no silently-dropped reply. + await deps.conversations.send({ + sessionId, chatId: msg.chatId, type: 'text', text: truncate(sanitize(text)), replyTo: msg.id, + }); + await markSeen(deps.storage, sessionId, msg.id, deps.now()); +} + +/** + * HTTP Action Bot — trigger a safe REST request from a WhatsApp command and render the JSON response + * back to chat. One request per message, one reply. Roadmap §4. + */ +export default class HttpActionPlugin implements IPlugin { + private ctx: PluginContext | null = null; + + async onEnable(ctx: PluginContext): Promise { + this.ctx = ctx; + const cfg = readConfig(ctx.config); // fail-fast: a bad config aborts enable + const cooldown = new Map(); // per-chat cooldown, lives for the enabled lifetime + + ctx.registerHook('message:received', async (h: HookContext): Promise => { + const sessionId = h.sessionId; + const msg = h.data as IncomingMessage | undefined; + if (!sessionId || !msg) return { continue: true }; + if (msg.fromMe) return { continue: true }; + if (typeof msg.body !== 'string' || msg.body.length === 0) return { continue: true }; + if (!msg.chatId || !msg.id) return { continue: true }; + + // Re-read config per event so a live dashboard edit is picked up without re-enable. + let liveCfg: HttpActionConfig; + try { + liveCfg = readConfig(ctx.config); + } catch (e) { + ctx.logger.warn(`${PLUGIN}: skipping message, config invalid: ${(e as Error).message}`); + return { continue: true }; + } + if (msg.isGroup && !liveCfg.respondInGroups) return { continue: true }; + + // Off-dispatch (§1.2 #2): return {continue:true} synchronously and float handleMessage, so a slow + // or blocked upstream never stalls the WA hook (the ~5s host hook budget is sync-return only). + void handleMessage( + { + cfg: liveCfg, + fetch: ctx.net.fetch.bind(ctx.net), + conversations: ctx.conversations, + storage: ctx.storage, + cooldown, + now: () => Date.now(), + logger: ctx.logger, + }, + sessionId, + msg, + ).catch((e) => ctx.logger.error(`${PLUGIN}: handler failed`, e)); + return { continue: true }; + }); + + ctx.logger.log(`${PLUGIN} enabled (${cfg.actions.length} action(s), ${cfg.baseUrl})`); + } + + async healthCheck(): Promise<{ healthy: boolean; message?: string }> { + if (!this.ctx) return { healthy: false, message: `${PLUGIN}: not loaded` }; + try { + const cfg = readConfig(this.ctx.config); + return { healthy: true, message: `${PLUGIN}: ${cfg.actions.length} action(s), baseUrl ${cfg.baseUrl}` }; + } catch (e) { + return { healthy: false, message: (e as Error).message }; + } + } +} diff --git a/http-action/manifest.json b/http-action/manifest.json new file mode 100644 index 0000000..4066ccb --- /dev/null +++ b/http-action/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "http-action", + "name": "HTTP Action Bot", + "version": "0.1.0", + "type": "extension", + "main": "dist/index.js", + "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", + "author": "Yudhi Armyndharis ", + "license": "MIT", + "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action", + "repository": "https://github.com/rmyndharis/OpenWA-plugins", + "keywords": ["api", "rest", "automation", "connector", "whatsapp", "openwa"], + "status": "development", + "sdkVersion": "1", + "provides": ["api-automation", "rest-connector", "dynamic-reply"], + "permissions": ["net:fetch", "conversation:send"], + "net": { "allow": [], "allowConfigHosts": ["baseUrl"] }, + "sessionScoped": true, + "sessions": ["*"], + "hooks": ["message:received"], + "configSchema": { + "type": "object", + "properties": { + "baseUrl": { "type": "string", "title": "Base URL", "required": true, + "description": "Origin API HTTPS, mis. https://erp.example.com. Host-nya auto-added ke allowlist outbound (allowConfigHosts). Wajib non-empty — tanpa ini setiap fetch diam-diam di-gate jadi no-op." }, + "authType": { "type": "string", "enum": ["none", "bearer", "apikey"], "default": "none" }, + "authToken": { "type": "string", "title": "Token / API key", "secret": true, + "description": "Bearer token atau API key (sesuai authType). Dimasking ***." }, + "apiKeyHeader": { "type": "string", "default": "X-API-Key", + "description": "Nama header untuk authType=apikey." }, + "respondInGroups": { "type": "boolean", "default": false }, + "timeoutMs": { "type": "number", "default": 3000, "min": 500 }, + "cooldownSeconds": { "type": "number", "default": 3, "min": 0 }, + "actions": { "type": "textarea", "title": "Actions (JSON array)", "required": true, + "description": "Array action sebagai JSON string. Contoh: [{\"id\":\"check-order\",\"match\":{\"type\":\"prefix\",\"value\":\"cek-order \"},\"request\":{\"method\":\"GET\",\"path\":\"/orders/{{args.0}}\"},\"replyTemplate\":\"Pesanan {{response.orderId}}: {{response.status}}\"}]. Di-parse di config.ts." } + } + } +} diff --git a/http-action/matcher.test.ts b/http-action/matcher.test.ts new file mode 100644 index 0000000..6ad53be --- /dev/null +++ b/http-action/matcher.test.ts @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { matchAction, parseArgs } from './matcher.ts'; +import type { HttpAction } from './config.ts'; + +const mk = (match: { type?: 'exact' | 'prefix'; value: string; caseSensitive?: boolean }, id = 'a'): HttpAction => ({ + id, + match: { type: match.type ?? 'prefix', value: match.value, caseSensitive: match.caseSensitive ?? false }, + request: { method: 'GET', path: '/x' }, + replyTemplate: 'r', +}); + +test('prefix match returns the action + the trailing args', () => { + const out = matchAction([mk({ value: 'cek-order ' })], 'cek-order INV-001'); + assert.ok(out); + assert.equal(out!.action.id, 'a'); + assert.deepEqual(out!.args, ['INV-001']); +}); + +test('exact match returns the action with empty args', () => { + const out = matchAction([mk({ type: 'exact', value: 'ping' })], 'ping'); + assert.ok(out); + assert.deepEqual(out!.args, []); +}); + +test('exact does not match when there is trailing text', () => { + assert.equal(matchAction([mk({ type: 'exact', value: 'ping' })], 'ping extra'), null); +}); + +test('no match returns null (silent)', () => { + assert.equal(matchAction([mk({ value: 'cek-order ' })], 'hello world'), null); +}); + +test('prefix is case-insensitive by default, but args keep their original case', () => { + const out = matchAction([mk({ value: 'cek-order ' })], 'CEK-ORDER InvX'); + assert.ok(out); + assert.deepEqual(out!.args, ['InvX']); +}); + +test('caseSensitive prefix blocks mismatched case', () => { + assert.equal(matchAction([mk({ value: 'cek-order ', caseSensitive: true })], 'CEK-ORDER x'), null); +}); + +test('first matching action wins (config order)', () => { + const a = mk({ value: 'cek ' }, 'first'); + const b = mk({ value: 'cek ' }, 'second'); + const out = matchAction([a, b], 'cek x'); + assert.equal(out!.action.id, 'first'); +}); + +test('quoted arg is kept as one token', () => { + assert.deepEqual(parseArgs('"INV 001" note'), ['INV 001', 'note']); + const out = matchAction([mk({ value: 'cek ' })], 'cek "INV 001"'); + assert.deepEqual(out!.args, ['INV 001']); +}); + +test('double spaces between tokens are stable', () => { + assert.deepEqual(parseArgs('a b'), ['a', 'b']); + const out = matchAction([mk({ value: 'cek ' })], 'cek a'); + assert.deepEqual(out!.args, ['a']); +}); + +test('prefix with empty remainder yields empty args', () => { + const out = matchAction([mk({ value: 'cek ' })], 'cek '); + assert.ok(out); + assert.deepEqual(out!.args, []); +}); diff --git a/http-action/matcher.ts b/http-action/matcher.ts new file mode 100644 index 0000000..5c96f51 --- /dev/null +++ b/http-action/matcher.ts @@ -0,0 +1,36 @@ +// Trigger matcher + argument parser for HTTP Action Bot. Pure (no ctx) → tests without OpenWA. +// +// Picks the FIRST action (config order) whose match clause fits the message body, and parses the trailing +// text into args. Case-insensitive by default (the toggle lives on each action's match). Args preserve the +// original message case; only the trigger comparison is case-folded. + +import type { HttpAction } from './config.ts'; + +export interface MatchResult { + action: HttpAction; + args: string[]; +} + +/** Tokenize on whitespace, keeping a double-quoted run as one token. */ +export function parseArgs(s: string): string[] { + const out: string[] = []; + const re = /"([^"]*)"|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s)) !== null) out.push(m[1] ?? m[2]); + return out; +} + +/** First-match-wins over `actions` for `body`, or null when none match. */ +export function matchAction(actions: HttpAction[], body: string): MatchResult | null { + for (const action of actions) { + const { type, value, caseSensitive } = action.match; + const hay = caseSensitive ? body : body.toLowerCase(); + const needle = caseSensitive ? value : value.toLowerCase(); + if (type === 'exact') { + if (hay === needle) return { action, args: [] }; + } else if (hay.startsWith(needle)) { + return { action, args: parseArgs(body.slice(value.length)) }; + } + } + return null; +} diff --git a/http-action/reliability.test.ts b/http-action/reliability.test.ts new file mode 100644 index 0000000..a95da84 --- /dev/null +++ b/http-action/reliability.test.ts @@ -0,0 +1,136 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hasSeen, markSeen, prune, allowCooldown, type StorageLike, DEDUP_TTL_MS } from './reliability.ts'; + +// Minimal in-memory StorageLike for tests. Flags simulate storage errors. +function fakeStore(opts: { listFail?: boolean; getFail?: boolean; setFail?: boolean } = {}): StorageLike & { m: Map } { + const m = new Map(); + return { + m, + get: async (key: string): Promise => { + if (opts.getFail) throw new Error('get'); + return m.has(key) ? (m.get(key) as T) : null; + }, + set: async (key: string, val: unknown) => { + if (opts.setFail) throw new Error('set'); + m.set(key, val); + }, + delete: async (key: string) => { m.delete(key); }, + list: async (prefix?: string) => { + if (opts.listFail) throw new Error('list'); + return [...m.keys()].filter((k) => (prefix ? k.startsWith(prefix) : true)); + }, + }; +} + +// ---- hasSeen (presence-based, fail-closed) ---- + +test('hasSeen: false before a message is marked', async () => { + const s = fakeStore(); + assert.equal(await hasSeen(s, 'sess', 'm1'), false); +}); + +test('hasSeen: true after markSeen', async () => { + const s = fakeStore(); + await markSeen(s, 'sess', 'm1', 1000); + assert.equal(await hasSeen(s, 'sess', 'm1'), true); +}); + +test('hasSeen: distinct message ids are independent', async () => { + const s = fakeStore(); + await markSeen(s, 'sess', 'm1', 1000); + assert.equal(await hasSeen(s, 'sess', 'm2'), false); +}); + +test('hasSeen: a storage get error fails CLOSED (drop, never double-process)', async () => { + const s = fakeStore({ getFail: true }); + assert.equal(await hasSeen(s, 'sess', 'm1'), true); +}); + +test('hasSeen: presence-based — robust to the stored value type (does not require a number)', async () => { + // Simulate a storage backend that returns the marker in a different shape; presence still wins. + const s = fakeStore(); + s.m.set('dedup:sess:m1', { t: '1000' }); // t stringified, not a number + assert.equal(await hasSeen(s, 'sess', 'm1'), true); +}); + +test('markSeen: a storage set error is swallowed (best-effort)', async () => { + const s = fakeStore({ setFail: true }); + await markSeen(s, 'sess', 'm1', 1000); // does not throw +}); + +// ---- prune (throttled, best-effort, reads {t} objects) ---- + +test('prune: deletes markers older than the TTL, keeps the rest', async () => { + const s = fakeStore(); + s.m.set('dedup:sess:old', { t: 1000 }); + s.m.set('dedup:sess:fresh', { t: 50000 }); + const out = await prune(s, 60000, 10000, 1000); + assert.equal(out.ran, true); + assert.equal(out.pruned, 1); + assert.equal(s.m.has('dedup:sess:old'), false); + assert.equal(s.m.has('dedup:sess:fresh'), true); +}); + +test('prune: is throttled by the interval (skips when recently run)', async () => { + const s = fakeStore(); + s.m.set('dedup:__prune__', { t: 59500 }); + s.m.set('dedup:sess:old', { t: 1000 }); + const out = await prune(s, 60000, 10000, 1000); // 500 < 1000 → not due + assert.equal(out.ran, false); + assert.equal(s.m.has('dedup:sess:old'), true); +}); + +test('prune: ignores keys outside the dedup prefix (defensive)', async () => { + const s = fakeStore(); + s.m.set('dedup:sess:old', { t: 1000 }); + s.m.set('other:kind:key', { t: 1000 }); + const out = await prune(s, 60000, 10000, 1000); + assert.equal(out.pruned, 1); + assert.equal(s.m.has('other:kind:key'), true); +}); + +test('prune: leaves a malformed (non-{t}) marker alone rather than deleting blindly', async () => { + const s = fakeStore(); + s.m.set('dedup:sess:weird', 'not-an-object'); // no .t number → not aged out by prune + const out = await prune(s, 60000, 10000, 1000); + assert.equal(out.pruned, 0); + assert.equal(s.m.has('dedup:sess:weird'), true); +}); + +test('prune: never throws on storage errors (best-effort)', async () => { + const s = fakeStore({ listFail: true }); + const out = await prune(s, 60000, 10000, 1000); + assert.equal(out.ran, true); + assert.equal(out.pruned, 0); +}); + +// ---- allowCooldown (in-memory, fail-open, LRU-capped) ---- + +test('allowCooldown: first call allows', () => { + const m = new Map(); + assert.equal(allowCooldown(m, 'c1', 1000, 3000), true); +}); + +test('allowCooldown: blocks within the window', () => { + const m = new Map(); + allowCooldown(m, 'c1', 1000, 3000); + assert.equal(allowCooldown(m, 'c1', 3999, 3000), false); +}); + +test('allowCooldown: allows after the window elapses', () => { + const m = new Map(); + allowCooldown(m, 'c1', 1000, 3000); + assert.equal(allowCooldown(m, 'c1', 4000, 3000), true); +}); + +test('allowCooldown: distinct chats are independent', () => { + const m = new Map(); + assert.equal(allowCooldown(m, 'c1', 1000, 3000), true); + assert.equal(allowCooldown(m, 'c2', 1000, 3000), true); +}); + +test('DEDUP_TTL_MS export is a positive number (3 days)', () => { + assert.ok(DEDUP_TTL_MS > 0); + assert.equal(DEDUP_TTL_MS, 3 * 24 * 60 * 60 * 1000); +}); diff --git a/http-action/reliability.ts b/http-action/reliability.ts new file mode 100644 index 0000000..5f3e128 --- /dev/null +++ b/http-action/reliability.ts @@ -0,0 +1,111 @@ +// Reliability gates for HTTP Action Bot: idempotency (dedup) + per-chat cooldown + storage pruning. +// +// Dedup is split into a read-only presence CHECK (hasSeen, fail-closed) and a MARK written only AFTER a +// successful reply (markSeen) — mirroring chatwoot's hasSeen/markSeen split, so a transient send failure +// leaves the message un-marked and a WhatsApp redelivery retries instead of being silently dropped. The +// marker is an object {t} and the dup decision is presence-based, so it does not hinge on the storage +// bridge preserving a bare number type. Cooldown is in-memory and FAIL-OPEN (it never throws, so it can +// never wrongly block). Pure modulo the injected storage. + +export interface StorageLike { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; + list(prefix?: string): Promise; +} + +const KEY_PREFIX = 'dedup:'; +const PRUNE_KEY = 'dedup:__prune__'; + +/** Re-delivery window. WhatsApp redelivers within minutes; 3 days is generous and mirrors the repo norm. */ +export const DEDUP_TTL_MS = 3 * 24 * 60 * 60 * 1000; +export const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // hourly + +interface Marker { + t?: unknown; +} + +const dedupKey = (sessionId: string, msgId: string): string => `${KEY_PREFIX}${sessionId}:${msgId}`; + +/** Read-only presence check. True if `msgId` is already marked (or on storage error → fail-closed drop). */ +export async function hasSeen(storage: StorageLike, sessionId: string, msgId: string): Promise { + try { + const v = await storage.get(dedupKey(sessionId, msgId)); + return v !== null && v !== undefined; + } catch { + return true; // fail-closed: can't read → drop rather than risk a double-fire + } +} + +/** Record a marker AFTER a successful reply so a failed send retries on redelivery. Best-effort. */ +export async function markSeen(storage: StorageLike, sessionId: string, msgId: string, now: number): Promise { + try { + await storage.set(dedupKey(sessionId, msgId), { t: now }); + } catch { + /* best-effort: a redelivery may re-fire, which is the safer failure mode */ + } +} + +/** Delete dedup markers older than `ttlMs`. Throttled by a persisted last-prune timestamp; best-effort. */ +export async function prune( + storage: StorageLike, + now: number, + ttlMs: number, + intervalMs: number, +): Promise<{ ran: boolean; pruned: number }> { + let last: Marker | null; + try { + last = await storage.get(PRUNE_KEY); + } catch { + last = null; + } + if (last !== null && typeof last.t === 'number' && now - last.t < intervalMs) { + return { ran: false, pruned: 0 }; + } + try { + await storage.set(PRUNE_KEY, { t: now }); + } catch { + /* best-effort: still attempt the sweep */ + } + + let keys: string[]; + try { + keys = (await storage.list(KEY_PREFIX)).filter((k) => k.startsWith(KEY_PREFIX) && k !== PRUNE_KEY); + } catch { + return { ran: true, pruned: 0 }; + } + + let pruned = 0; + for (const k of keys) { + let m: Marker | null; + try { + m = await storage.get(k); + } catch { + continue; + } + if (m !== null && typeof m.t === 'number' && now - m.t > ttlMs) { + try { + await storage.delete(k); + pruned++; + } catch { + /* leave it for next sweep */ + } + } + } + return { ran: true, pruned }; +} + +const MAX_COOLDOWN_ENTRIES = 5000; + +/** In-memory per-key cooldown, LRU-capped. True if allowed now (and records the touch); false if within the window. */ +export function allowCooldown(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; + if (oldest !== undefined) map.delete(oldest); + } + return true; +} diff --git a/http-action/url-template.test.ts b/http-action/url-template.test.ts new file mode 100644 index 0000000..b88b463 --- /dev/null +++ b/http-action/url-template.test.ts @@ -0,0 +1,135 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderText, renderPath, renderJson, renderHeader, getPath } from './url-template.ts'; + +const ctx = (over: Record = {}) => ({ + args: ['INV-001'], + response: { orderId: 'INV-001', status: 'shipped', trackingNumber: 'JNE123', items: [{ name: 'Widget' }] }, + ...over, +}); + +// ---- renderText: reply / error / notFound templates ---- + +test('renderText substitutes a top-level response path', () => { + assert.equal(renderText('Status: {{response.status}}', ctx()), 'Status: shipped'); +}); + +test('renderText substitutes a nested path into a primitive', () => { + assert.equal(renderText('{{response.items.0.name}}', ctx()), 'Widget'); +}); + +test('renderText substitutes several placeholders in one template', () => { + assert.equal(renderText('{{response.orderId}} -> {{response.status}}', ctx()), 'INV-001 -> shipped'); +}); + +test('renderText: a missing value renders as empty string', () => { + assert.equal(renderText('[{{response.unknown}}]', ctx()), '[]'); +}); + +test('renderText: literal text without placeholders passes through unchanged', () => { + assert.equal(renderText('no placeholders here', ctx()), 'no placeholders here'); +}); + +test('renderText: substitutes args.N', () => { + assert.equal(renderText('arg0={{args.0}}', ctx()), 'arg0=INV-001'); +}); + +// ---- prototype pollution defense (the core security property) ---- + +test('renderText rejects __proto__ in the path', () => { + assert.throws(() => renderText('{{__proto__.x}}', ctx()), /prototype|__proto__/i); +}); + +test('renderText rejects constructor / prototype keys anywhere in the path', () => { + assert.throws(() => renderText('{{response.constructor}}', ctx()), /prototype|constructor/i); + assert.throws(() => renderText('{{a.constructor.prototype}}', ctx()), /prototype|constructor/i); +}); + +// ---- depth + count caps (DoS bound) ---- + +test('renderText rejects a path deeper than the cap', () => { + const deep = 'a' + '.a'.repeat(20); + assert.throws(() => renderText(`{{${deep}}}`, ctx()), /depth|too deep|long/i); +}); + +test('renderText rejects a template with too many placeholders', () => { + const many = '{{response.status}} '.repeat(200); + assert.throws(() => renderText(many, ctx()), /too many|count|placeholder/i); +}); + +// ---- renderPath: URL-encoded segments (SSRF / path-injection defense) ---- + +test('renderPath encodes a substituted segment value', () => { + assert.equal(renderPath('/orders/{{args.0}}', ctx({ args: ['INV 001'] })), '/orders/INV%20001'); +}); + +test('renderPath encodes an embedded slash so an arg cannot add a path segment', () => { + assert.equal(renderPath('/orders/{{args.0}}', ctx({ args: ['a/b'] })), '/orders/a%2Fb'); +}); + +test('renderPath leaves literal path structure intact', () => { + assert.equal(renderPath('/orders/{{args.0}}/items', ctx({ args: ['X'] })), '/orders/X/items'); +}); + +test('renderPath is prototype-safe', () => { + assert.throws(() => renderPath('/x/{{__proto__.y}}', ctx()), /prototype|__proto__/i); +}); + +// ---- renderJson: POST body, JSON-safe substitution ---- + +test('renderJson substitutes a value into a JSON string field with safe escaping', () => { + const out = renderJson('{"q":"{{args.0}}"}', ctx({ args: ['x"y'] })); + assert.equal(out, '{"q":"x\\"y"}'); + assert.deepEqual(JSON.parse(out), { q: 'x"y' }); // re-parses cleanly +}); + +test('renderJson escapes backslashes', () => { + const out = renderJson('{"q":"{{args.0}}"}', ctx({ args: ['a\\b'] })); + assert.deepEqual(JSON.parse(out), { q: 'a\\b' }); +}); + +test('renderJson is prototype-safe', () => { + assert.throws(() => renderJson('{{__proto__.x}}', ctx()), /prototype|__proto__/i); +}); + +test('renderJson returns a valid JSON string for a plain value', () => { + const out = renderJson('{"code":"{{args.0}}"}', ctx({ args: ['INV-001'] })); + assert.deepEqual(JSON.parse(out), { code: 'INV-001' }); +}); + +// ---- getPath: the shared prototype-safe walker ---- + +test('getPath walks a dotted path and returns the value', () => { + assert.equal(getPath({ a: { b: { c: 5 } } }, 'a.b.c'), 5); +}); + +test('getPath returns undefined for a missing path (no throw)', () => { + assert.equal(getPath({ a: 1 }, 'a.b.c'), undefined); +}); + +test('getPath rejects a prototype key segment', () => { + assert.throws(() => getPath({}, '__proto__'), /prototype|__proto__/i); + assert.throws(() => getPath({ a: {} }, 'a.constructor'), /prototype|constructor/i); +}); + +// ---- renderPath: '..' traversal block ---- + +test('renderPath rejects a ".." segment (same-origin traversal blocked)', () => { + assert.throws(() => renderPath('/orders/{{args.0}}', { args: ['..'] }), /traversal|\.\./i); + assert.throws(() => renderPath('/orders/{{args.0}}', { args: ['../..'] }), /traversal|\.\./i); +}); + +test('renderPath keeps benign dotted values (version numbers, no "..")', () => { + assert.equal(renderPath('/v/{{args.0}}', { args: ['1.0.3'] }), '/v/1.0.3'); +}); + +// ---- renderHeader: CR/LF/NUL injection block ---- + +test('renderHeader rejects a rendered value containing CR/LF (header injection blocked)', () => { + assert.throws(() => renderHeader('{{args.0}}', { args: ['a\nb'] }), /CR\/LF|header/i); + assert.throws(() => renderHeader('{{args.0}}', { args: ['a\r\nInjected: x'] }), /CR\/LF|header/i); +}); + +test('renderHeader accepts a clean value', () => { + assert.equal(renderHeader('X-Trace: {{message.id}}', { args: [], message: { id: 'm1' } }), 'X-Trace: m1'); +}); diff --git a/http-action/url-template.ts b/http-action/url-template.ts new file mode 100644 index 0000000..14b94f3 --- /dev/null +++ b/http-action/url-template.ts @@ -0,0 +1,88 @@ +// Prototype-safe dot-path templating for HTTP Action Bot. Pure (no ctx) → tests without OpenWA. +// +// Security: WhatsApp message text is attacker-controlled and flows into `args.*` (and from there into +// request paths, query, and POST bodies). Three invariants this module enforces: +// 1. prototype keys (__proto__ / constructor / prototype) are rejected anywhere in a path; +// 2. path/query segments are URL-encoded so an arg can never inject a new path segment or origin; +// 3. POST body values are JSON-escaped so an arg can never break out of a JSON string field. +// Bounded (max path depth + max placeholder count) so a template can't DoS the worker. + +export interface TemplateContext { + args: string[]; + response?: unknown; + sender?: Record; + chat?: Record; + message?: Record; + session?: Record; +} + +const PROTOTYPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const MAX_DEPTH = 12; +const MAX_PLACEHOLDERS = 64; +const PLACEHOLDER_RE = /\{\{(.*?)\}\}/g; + +export class TemplateError extends Error { + constructor(msg: string) { + super(`http-action: template error — ${msg}`); + this.name = 'TemplateError'; + } +} + +function toStr(v: unknown): string { + if (v === null || v === undefined) return ''; + return typeof v === 'string' ? v : JSON.stringify(v); +} + +/** Walk a dotted path on `root`, prototype-safe. Returns undefined for a missing path (no throw). */ +export function getPath(root: unknown, dotted: string): unknown { + const segs = dotted.split('.'); + if (segs.length > MAX_DEPTH) throw new TemplateError(`path too deep (>${MAX_DEPTH}): ${dotted}`); + let cur: unknown = root; + for (const seg of segs) { + if (PROTOTYPE_KEYS.has(seg)) throw new TemplateError(`prototype key forbidden in path: ${dotted}`); + if (cur === null || cur === undefined) return undefined; + if (typeof cur !== 'object') return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +/** Plain substitution for reply/error/notFound templates. Missing value → ''. */ +export function renderText(template: string, ctx: TemplateContext): string { + return render(template, ctx, toStr); +} + +/** Substitute {{...}} into a path template. URL-encodes each value and rejects '..' (same-origin traversal). */ +export function renderPath(template: string, ctx: TemplateContext): string { + return render(template, ctx, (v) => { + const s = toStr(v); + if (s.includes('..')) throw new TemplateError('path segment contains ".." (traversal blocked)'); + return encodeURIComponent(s); + }); +} + +/** Substitute {{...}} into a header value, rejecting CR/LF/NUL so an attacker-controlled field can't inject headers. */ +export function renderHeader(template: string, ctx: TemplateContext): string { + return render(template, ctx, (v) => { + const s = toStr(v); + if (/[\r\n\0]/.test(s)) throw new TemplateError('header value contains CR/LF/NUL'); + return s; + }); +} + +/** Substitute {{...}} into a JSON body template with JSON-safe string escaping. */ +export function renderJson(template: string, ctx: TemplateContext): string { + // Stringify the value (which escapes quotes/backslashes) then strip the outer quotes, so it slots + // into a quoted JSON field safely. The client re-parses the result before sending. + return render(template, ctx, (v) => JSON.stringify(toStr(v)).slice(1, -1)); +} + +function render(template: string, ctx: TemplateContext, encode: (v: unknown) => string): string { + let count = 0; + return template.replace(PLACEHOLDER_RE, (_m, innerRaw: string) => { + if (++count > MAX_PLACEHOLDERS) throw new TemplateError(`too many placeholders (>${MAX_PLACEHOLDERS})`); + const inner = String(innerRaw).trim(); + if (!inner) return ''; + return encode(getPath(ctx, inner)); + }); +} diff --git a/package.json b/package.json index e59f2f8..c0d4dc3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "for d in */; do if [ -f \"${d}manifest.json\" ]; then node package.mjs \"${d%/}\"; fi; done", "catalog": "node scripts/catalog.mjs", "catalog:check": "node scripts/catalog.mjs --check", - "test": "node --import tsx --test \"gsheets-logger/*.test.ts\" \"faq-bot/*.test.ts\" \"after-hours/*.test.ts\" \"chat-flow/*.test.ts\" \"group-translate/**/*.test.ts\" \"voice-transcription/**/*.test.ts\" \"chatwoot-adapter/**/*.test.ts\" \"typebot-connector/**/*.test.ts\"", + "test": "node --import tsx --test \"gsheets-logger/*.test.ts\" \"faq-bot/*.test.ts\" \"after-hours/*.test.ts\" \"chat-flow/*.test.ts\" \"group-translate/**/*.test.ts\" \"voice-transcription/**/*.test.ts\" \"chatwoot-adapter/**/*.test.ts\" \"typebot-connector/**/*.test.ts\" \"http-action/*.test.ts\"", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/plugins.json b/plugins.json index 61c688d..84264c5 100644 --- a/plugins.json +++ b/plugins.json @@ -1002,6 +1002,31 @@ } } }, + { + "id": "http-action", + "name": "HTTP Action Bot", + "version": "0.1.0", + "type": "extension", + "status": "development", + "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", + "author": "Yudhi Armyndharis ", + "license": "MIT", + "keywords": [ + "api", + "rest", + "automation", + "connector", + "whatsapp", + "openwa" + ], + "minOpenWAVersion": null, + "testedOpenWAVersion": null, + "releasedAt": "2026-07-11", + "repoPath": "http-action", + "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", + "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.1.0/http-action.zip" + }, { "id": "typebot-connector", "name": "Typebot Connector", diff --git a/tsconfig.json b/tsconfig.json index 9104da7..c5944de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "voice-transcription/**/*.ts", "chatwoot-adapter/**/*.ts", "typebot-connector/**/*.ts", + "http-action/**/*.ts", "types/**/*.d.ts" ] }