Skip to content

Commit 3e89170

Browse files
authored
feat(http-action): safe REST action bot plugin (0.1.0) (#34)
* feat(http-action): add REST action bot plugin (0.1.0) Trigger safe REST API requests from WhatsApp commands and map JSON responses back to chat. One request per message, one reply. Safe by default: fixed https origin (allowConfigHosts, required), server-relative path validation, dangerous-header blocklist, CRLF rejection, prototype-safe templating with URL-encoded path segments and JSON-safe POST bodies, 256 KiB response cap, and an off-dispatch handler so a slow upstream never stalls the inbound hook. 86 tests (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. Dedup/cooldown hardening is deferred to a follow-up. * feat(http-action): add dedup + per-chat cooldown Storage-backed idempotency (claim, fail-closed, 3-day TTL) so a redelivered message id never double-fires; a throttled prune keeps ctx.storage from growing unbounded. In-memory per-chat cooldown (allowCooldown, fail-open, LRU-capped) rate-limits a single chat. Both gates run in handleMessage before the upstream call. Updated the 0.1.0 changelog to cover the full MVP. 106 tests; typecheck, catalog:check, build, and the three end-to-end use cases still pass. * fix(http-action): harden config/fetch/dedup and enroll in typecheck Review-driven fixes: - Enroll http-action in tsconfig include so the CI typecheck actually validates it; it had been excluded, masking a type error. FetchResponse.statusText is now optional to match PluginNetResponse. - Reject CR/LF/NUL in rendered header values (a templated attacker field could otherwise inject headers), reject '..' path segments, reject a query string in baseUrl, and screen apiKeyHeader against the dangerous- header blocklist. - Rework dedup into a read-only hasSeen check plus a mark written only after a successful send, so a transient send failure retries on redelivery instead of being dropped; the marker is presence-based and cooldown runs before any mark. - Route request/prune failures through logger.error so the Error context is kept, surface the parsed body to notFound/error templates, avoid splitting a surrogate pair when truncating, and strip C0 control chars from rendered replies. 381 tests; typecheck (now covering http-action), catalog:check, build, and the three end-to-end use cases all pass.
1 parent 4aaf81e commit 3e89170

19 files changed

Lines changed: 1739 additions & 1 deletion

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ This repository provides:
4040
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.6 | stable |
4141
| [`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 |
4242
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable |
43+
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.0 | development |
4344
| [`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 |
4445
| [`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 |
4546
<!-- END PLUGIN CATALOG -->

http-action/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
All notable changes to HTTP Action Bot are listed here. Versions follow [Semantic Versioning](https://semver.org/),
4+
and the top entry's version must match `manifest.json`.
5+
6+
## [0.1.0] — 2026-07-11
7+
8+
### Added
9+
- 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).
10+
- 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.
11+
- 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.
12+
- 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)`.
13+
- Matcher (`matcher.ts`): `exact`/`prefix` + case toggle + quoted-argument parsing, first-match-wins.
14+
- Handler (`handleMessage`): match → fetch → status mapping (2xx/404/other) → render → `conversations.send` (quoted text reply), with default templates and a 4000-char reply cap.
15+
- 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).
16+
- 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.

http-action/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# HTTP Action Bot
2+
3+
> Jalankan REST API aman dari command WhatsApp dan ubah respons JSON menjadi balasan chat.
4+
5+
<!-- BEGIN DETAILS (generated by scripts/catalog.mjs — do not edit by hand) -->
6+
| Field | Value |
7+
| ----- | ----- |
8+
| **Identifier** | `http-action` |
9+
| **Version** | 0.1.0 |
10+
| **Released** | 2026-07-11 |
11+
| **Status** | development |
12+
| **Author** | Yudhi Armyndharis |
13+
| **License** | MIT |
14+
| **Type** | `extension` |
15+
| **Requires OpenWA** | ≥ null (tested null) |
16+
| **Keywords** | api, rest, automation, connector, whatsapp, openwa |
17+
| **Repository** | [OpenWA-plugins/http-action](https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action) |
18+
<!-- END DETAILS -->
19+
20+
## Features
21+
22+
- Trigger `exact` / `prefix` dari body pesan.
23+
- Method `GET` / `POST` JSON ke satu origin HTTPS tetap (`baseUrl`).
24+
- Auth: none / Bearer / API-key header.
25+
- Dot-path response mapping + reply template teks.
26+
- Direct chat aktif default; group opt-in. Cooldown per chat + dedup message ID.
27+
- **Safe by default:** origin tetap dari config (allowConfigHosts), path wajib relative, header berbahaya ditolak, secret dimasking.
28+
29+
## What it does
30+
31+
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.
32+
33+
## Setup
34+
35+
1. Siapkan endpoint API HTTPS yang ingin dihubungkan.
36+
2. Tentukan command + path + template untuk tiap aksi (lihat contoh config).
37+
3. Isi `baseUrl` (wajib HTTPS) dan auth bila perlu.
38+
39+
## Install
40+
41+
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.
42+
43+
## Configuration
44+
45+
| Field | Type | Default | Description |
46+
| --- | --- | --- | --- |
47+
| `baseUrl` | string |*(required)* | Origin API HTTPS. Host auto-added ke allowlist outbound. Wajib non-empty. |
48+
| `authType` | enum | `none` | `none` · `bearer` · `apikey` |
49+
| `authToken` | secret || Bearer token / API key (sesuai `authType`). Dimasking `***`. |
50+
| `apiKeyHeader` | string | `X-API-Key` | Nama header untuk `authType=apikey`. |
51+
| `respondInGroups` | boolean | `false` | Balas juga di group chat. |
52+
| `timeoutMs` | number | `3000` | Timeout request (min 500). |
53+
| `cooldownSeconds` | number | `3` | Cooldown per chat. |
54+
| `actions` | textarea |*(required)* | Array action sebagai JSON string (lihat contoh di configSchema). |
55+
56+
```json
57+
{
58+
"baseUrl": "https://erp.example.com",
59+
"authType": "bearer",
60+
"authToken": "***",
61+
"actions": [
62+
{
63+
"id": "check-order",
64+
"match": { "type": "prefix", "value": "cek-order " },
65+
"request": { "method": "GET", "path": "/orders/{{args.0}}" },
66+
"replyTemplate": "Pesanan {{response.orderId}}: {{response.status}}\nResi: {{response.trackingNumber}}",
67+
"notFoundTemplate": "Pesanan tidak ditemukan.",
68+
"errorTemplate": "Layanan status pesanan bermasalah. Coba lagi nanti."
69+
}
70+
]
71+
}
72+
```
73+
74+
## Compatibility
75+
76+
> `minOpenWAVersion` / `testedOpenWAVersion` belum diisi — plugin masih development. Akan diisi setelah uji install + runtime pada versi OpenWA nyata.
77+
78+
## Security
79+
80+
- **Origin tetap:** `baseUrl` wajib HTTPS tanpa credential; host hanya dari config (allowConfigHosts), tidak pernah dari pesan.
81+
- **Path aman:** wajib relative (`/...`), bukan protocol-relative (`//`) atau absolute URL; fragment & control char ditolak.
82+
- **Header:** hop-by-hop + `x-forwarded-*` + CRLF ditolak (anti header injection).
83+
- **No arbitrary code:** tidak ada `eval`/regex bebas/loop. Satu request per pesan.
84+
- **Redirect lintas host:** di luar kendali plugin (tidak ada opsi `redirect` di `ctx.net.fetch`) — bergantung pada host proxy. Lihat catatan di spesifikasi internal.
85+
- **Secret:** `authToken` ditandai `secret` di configSchema (dimasking `***`, di-preserve saat round-trip).
86+
87+
## Changelog
88+
89+
See [CHANGELOG.md](./CHANGELOG.md).
90+
91+
## License
92+
93+
MIT

http-action/client.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { HttpActionClient, type FetchLike, type FetchResponse } from './client.ts';
4+
import { readConfig, type HttpAction, type HttpActionConfig } from './config.ts';
5+
6+
interface Captured {
7+
url?: string;
8+
init?: { method?: string; headers?: Record<string, string>; body?: string; timeoutMs?: number };
9+
}
10+
11+
// A recording fake mirroring PluginNetCapability.fetch(url, init). Returns a canned response, captures the call.
12+
function fakeFetch(capture: Captured, res: { ok?: boolean; status?: number; body: string }): FetchLike {
13+
return async (url: string, init?: Captured['init']): Promise<FetchResponse> => {
14+
capture.url = url;
15+
capture.init = init;
16+
return { ok: res.ok ?? true, status: res.status ?? 200, statusText: 'OK', headers: {}, body: res.body };
17+
};
18+
}
19+
20+
function cfgWith(over: Record<string, unknown> = {}): { config: HttpActionConfig; action: HttpAction } {
21+
const config = readConfig({
22+
baseUrl: 'https://api.example.com',
23+
actions: JSON.stringify([{
24+
id: 'check', match: { type: 'prefix', value: 'cek ' },
25+
request: { method: 'GET', path: '/orders/{{args.0}}', query: { zone: '{{args.1}}' }, headers: { 'X-Trace': '{{message.id}}' } },
26+
replyTemplate: '{{response.status}}',
27+
}, {
28+
id: 'create', match: { type: 'prefix', value: 'buat ' },
29+
request: { method: 'POST', path: '/tickets', bodyTemplate: '{"desc":"{{args.0}}"}' },
30+
replyTemplate: '{{response.id}}',
31+
}]),
32+
...over,
33+
});
34+
return { config, action: config.actions[0] };
35+
}
36+
37+
test('GET builds the URL from baseUrl + rendered (encoded) path', async () => {
38+
const { config, action } = cfgWith();
39+
const cap: Captured = {};
40+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['INV 001'] });
41+
assert.equal(cap.url, 'https://api.example.com/orders/INV%20001');
42+
});
43+
44+
test('GET has no body', async () => {
45+
const { config, action } = cfgWith();
46+
const cap: Captured = {};
47+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
48+
assert.equal(cap.init?.body, undefined);
49+
});
50+
51+
test('GET appends an encoded query string from a templated value', async () => {
52+
const { config, action } = cfgWith();
53+
const cap: Captured = {};
54+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X', 'jakarta barat'] });
55+
assert.match(cap.url ?? '', /\?zone=jakarta%20barat$/);
56+
});
57+
58+
test('action header values are rendered', async () => {
59+
const { config, action } = cfgWith();
60+
const cap: Captured = {};
61+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1' } });
62+
assert.equal(cap.init?.headers?.['X-Trace'], 'm1');
63+
});
64+
65+
test('a header value with CR/LF (via an attacker-controlled arg) is rejected, never sent', async () => {
66+
const { config, action } = cfgWith();
67+
const cap: Captured = {};
68+
// action.request.headers = { 'X-Trace': '{{message.id}}' }; send a body whose id carries a newline
69+
await assert.rejects(
70+
() => new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1\nInjected: evil' } }),
71+
/CR\/LF|header/i,
72+
);
73+
assert.equal(cap.url, undefined); // fetch was never called
74+
});
75+
76+
test('bearer auth adds Authorization: Bearer <token>', async () => {
77+
const { config, action } = cfgWith({ authType: 'bearer', authToken: 'tok123' });
78+
const cap: Captured = {};
79+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
80+
assert.equal(cap.init?.headers?.['Authorization'], 'Bearer tok123');
81+
});
82+
83+
test('apikey auth adds the configured header name', async () => {
84+
const { config, action } = cfgWith({ authType: 'apikey', authToken: 'key123', apiKeyHeader: 'X-Api-Key' });
85+
const cap: Captured = {};
86+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
87+
assert.equal(cap.init?.headers?.['X-Api-Key'], 'key123');
88+
assert.equal(cap.init?.headers?.['Authorization'], undefined);
89+
});
90+
91+
test('none auth adds no auth header', async () => {
92+
const { config, action } = cfgWith();
93+
const cap: Captured = {};
94+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
95+
assert.equal(cap.init?.headers?.['Authorization'], undefined);
96+
});
97+
98+
test('POST sends a rendered, re-parsed JSON body with application/json content type', async () => {
99+
const { config } = cfgWith();
100+
const action = config.actions[1]; // 'create' POST
101+
const cap: Captured = {};
102+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['internet mati'] });
103+
assert.equal(cap.init?.method, 'POST');
104+
assert.equal(cap.init?.headers?.['Content-Type'], 'application/json');
105+
assert.deepEqual(JSON.parse(cap.init?.body ?? '{}'), { desc: 'internet mati' });
106+
});
107+
108+
test('parses a JSON response body into data', async () => {
109+
const { config, action } = cfgWith();
110+
const client = new HttpActionClient(fakeFetch({}, { body: JSON.stringify({ status: 'shipped' }) }), config);
111+
const out = await client.run(action, { args: ['X'] });
112+
assert.deepEqual(out.data, { status: 'shipped' });
113+
assert.equal(out.status, 200);
114+
});
115+
116+
test('a response body over the 256 KiB cap is rejected', async () => {
117+
const { config, action } = cfgWith();
118+
const big = 'x'.repeat(256 * 1024 + 1);
119+
const client = new HttpActionClient(fakeFetch({}, { body: big }), config);
120+
await assert.rejects(() => client.run(action, { args: ['X'] }), /too large|RESPONSE_TOO_LARGE/i);
121+
});
122+
123+
test('a non-JSON body on a 2xx response throws (UPSTREAM_INVALID_JSON)', async () => {
124+
const { config, action } = cfgWith();
125+
const client = new HttpActionClient(fakeFetch({}, { ok: true, status: 200, body: 'not json' }), config);
126+
await assert.rejects(() => client.run(action, { args: ['X'] }), /invalid json|UPSTREAM_INVALID_JSON/i);
127+
});
128+
129+
test('a non-ok status (404) is returned, not thrown, with the status', async () => {
130+
const { config, action } = cfgWith();
131+
const client = new HttpActionClient(fakeFetch({}, { ok: false, status: 404, body: '{"error":"none"}' }), config);
132+
const out = await client.run(action, { args: ['X'] });
133+
assert.equal(out.status, 404);
134+
assert.deepEqual(out.data, { error: 'none' });
135+
});
136+
137+
test('timeoutMs from config is passed to fetch init', async () => {
138+
const { config, action } = cfgWith({ timeoutMs: 2500 });
139+
const cap: Captured = {};
140+
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
141+
assert.equal(cap.init?.timeoutMs, 2500);
142+
});

http-action/client.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Fixed-origin HTTP client for HTTP Action Bot. Takes the host `ctx.net.fetch` (injected, so this tests
2+
// without OpenWA) and a validated config; builds a safe request per action + template context and parses
3+
// the JSON response. Pure modulo the injected fetch.
4+
//
5+
// The injected fetch matches the real PluginNetCapability.fetch(url, init) — URL is the first positional
6+
// arg, init carries method/headers/body/timeoutMs (NOT a `url` field). Security: origin is fixed to
7+
// cfg.baseUrl (config-validated https, allowConfigHosts); the path is rendered via renderPath (URL-encodes
8+
// each arg segment so an arg can't add a segment or change origin); query values are encodeURIComponent'd;
9+
// the POST body is JSON-escaped by renderJson and re-parsed before send. Response capped at 256 KiB.
10+
11+
import type { HttpAction, HttpActionConfig } from './config.ts';
12+
import { renderPath, renderText, renderJson, renderHeader, type TemplateContext } from './url-template.ts';
13+
14+
const MAX_RESPONSE_BYTES = 256 * 1024;
15+
16+
export interface FetchInit {
17+
method?: string;
18+
headers?: Record<string, string>;
19+
body?: string;
20+
timeoutMs?: number;
21+
}
22+
23+
export interface FetchResponse {
24+
ok: boolean;
25+
status: number;
26+
statusText?: string;
27+
headers: Record<string, string>;
28+
body: string;
29+
}
30+
31+
/** Mirrors PluginNetCapability.fetch(url, init) so ctx.net.fetch.bind(ctx.net) drops straight in. */
32+
export type FetchLike = (url: string, init?: FetchInit) => Promise<FetchResponse>;
33+
34+
export interface ActionResult {
35+
status: number;
36+
data: unknown; // parsed JSON, or undefined when a non-ok body wasn't JSON
37+
}
38+
39+
export class HttpActionClient {
40+
constructor(private readonly fetch: FetchLike, private readonly cfg: HttpActionConfig) {}
41+
42+
async run(action: HttpAction, ctx: TemplateContext): Promise<ActionResult> {
43+
const { url, init } = this.buildRequest(action, ctx);
44+
const res = await this.fetch(url, init);
45+
if (res.body.length > MAX_RESPONSE_BYTES) {
46+
throw new Error('http-action: upstream response too large (RESPONSE_TOO_LARGE)');
47+
}
48+
let data: unknown;
49+
try {
50+
data = res.body.length ? JSON.parse(res.body) : undefined;
51+
} catch {
52+
if (res.ok) throw new Error('http-action: upstream returned invalid JSON (UPSTREAM_INVALID_JSON)');
53+
data = undefined; // non-ok body may be a plaintext error; the handler maps via status template
54+
}
55+
return { status: res.status, data };
56+
}
57+
58+
private buildRequest(action: HttpAction, ctx: TemplateContext): { url: string; init: FetchInit } {
59+
const path = renderPath(action.request.path, ctx);
60+
let url = this.cfg.baseUrl + path;
61+
62+
if (action.request.query) {
63+
const qs = Object.entries(action.request.query)
64+
.map(([k, v]) => [k, renderText(v, ctx)] as const)
65+
.filter(([, val]) => val !== '') // omit params whose value renders empty (e.g. a missing arg)
66+
.map(([k, val]) => `${encodeURIComponent(k)}=${encodeURIComponent(val)}`)
67+
.join('&');
68+
if (qs) url += `?${qs}`;
69+
}
70+
71+
const headers: Record<string, string> = {};
72+
if (action.request.headers) {
73+
for (const [k, v] of Object.entries(action.request.headers)) headers[k] = renderHeader(v, ctx);
74+
}
75+
76+
// Auth — authToken is a configSchema secret; never logged by the caller.
77+
if (this.cfg.authType === 'bearer') headers['Authorization'] = `Bearer ${this.cfg.authToken}`;
78+
else if (this.cfg.authType === 'apikey') headers[this.cfg.apiKeyHeader] = this.cfg.authToken ?? '';
79+
80+
const init: FetchInit = { method: action.request.method, headers, timeoutMs: this.cfg.timeoutMs };
81+
82+
if (action.request.method === 'POST') {
83+
headers['Content-Type'] = 'application/json';
84+
if (action.request.bodyTemplate) {
85+
const rendered = renderJson(action.request.bodyTemplate, ctx);
86+
try {
87+
JSON.parse(rendered); // validate before send (anti JSON injection via an arg)
88+
} catch {
89+
throw new Error('http-action: rendered request body is not valid JSON');
90+
}
91+
init.body = rendered;
92+
}
93+
}
94+
95+
return { url, init };
96+
}
97+
}

0 commit comments

Comments
 (0)