@mulmobridge/webhook-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @mulmobridge/webhook-runtime
2
+
3
+ Shared HTTP-webhook plumbing for the MulmoClaude messaging bridges that
4
+ receive events over an inbound webhook (LINE, WhatsApp, Viber, LINE WORKS,
5
+ Messenger, Google Chat).
6
+
7
+ - `createWebhookApp({ bodyLimit? })` — Express app with `x-powered-by`
8
+ disabled, `BRIDGE_TRUST_PROXY` honoured, and raw-text body parsing so the
9
+ HMAC signature can be verified before JSON parsing.
10
+ - `configureTrustProxy(app, env?)` — parse `BRIDGE_TRUST_PROXY`
11
+ (boolean / hop-count / preset / CIDR) and apply it.
12
+ - `createWebhookRateLimit(limitPerMinute?)` — IPv6-safe per-IP rate limit.
13
+ - `verifyHmacSignature(body, signature, secret, algorithm?, encoding?)` —
14
+ length-guarded, timing-safe HMAC comparison.
15
+
16
+ These are security-relevant and hardened through Codex reviews (#1326);
17
+ keeping one copy means a fix lands once, not once per bridge.
@@ -0,0 +1,9 @@
1
+ import crypto from "crypto";
2
+ import { type Express } from "express";
3
+ import { type RateLimitRequestHandler } from "express-rate-limit";
4
+ export declare function configureTrustProxy(app: Express, env?: string | undefined): void;
5
+ export declare function createWebhookApp(opts?: {
6
+ bodyLimit?: string;
7
+ }): Express;
8
+ export declare function createWebhookRateLimit(limitPerMinute?: number): RateLimitRequestHandler;
9
+ export declare function verifyHmacSignature(body: string, signature: string, secret: string, algorithm?: string, encoding?: crypto.BinaryToTextEncoding): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,85 @@
1
+ // @mulmobridge/webhook-runtime — shared HTTP-webhook plumbing for the
2
+ // messaging bridges that receive events over an inbound webhook (LINE,
3
+ // WhatsApp, Viber, LINE WORKS, Google Chat, Messenger).
4
+ //
5
+ // Each of those bridges used to inline the same Express setup, the same
6
+ // `BRIDGE_TRUST_PROXY` parsing, the same rate-limit config and the same
7
+ // timing-safe HMAC check. Those are security-relevant and were hardened
8
+ // through several Codex reviews (#1326); keeping six copies means a fix
9
+ // has to be applied six times. This package is the single source.
10
+ import crypto from "crypto";
11
+ import express from "express";
12
+ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
13
+ // Honour an explicit `trust proxy` setting so `req.ip` (the rate-limit
14
+ // key) reflects the real client IP rather than the load balancer's.
15
+ // Default `false` for safety; operators behind a known LB choose from:
16
+ // - hop count: BRIDGE_TRUST_PROXY=1
17
+ // - boolean: BRIDGE_TRUST_PROXY=true / false
18
+ // - preset: BRIDGE_TRUST_PROXY=loopback
19
+ // - CIDR list: BRIDGE_TRUST_PROXY=10.0.0.0/8,192.168.0.0/16
20
+ // Without this every webhook looks like it comes from one IP and the
21
+ // limiter degrades into a global throttle. The boolean branch is
22
+ // required because Express does NOT auto-convert string "true"/"false"
23
+ // — without it, `BRIDGE_TRUST_PROXY=true` is read as a (never-matching)
24
+ // CIDR rule (Codex reviews on #1326).
25
+ function parseTrustProxyValue(env) {
26
+ const lower = env.toLowerCase();
27
+ if (lower === "true")
28
+ return true;
29
+ if (lower === "false")
30
+ return false;
31
+ const numeric = Number(env);
32
+ return Number.isInteger(numeric) && numeric >= 0 ? numeric : env;
33
+ }
34
+ export function configureTrustProxy(app, env = process.env.BRIDGE_TRUST_PROXY) {
35
+ if (!env)
36
+ return;
37
+ app.set("trust proxy", parseTrustProxyValue(env));
38
+ }
39
+ // The base Express app shared by every webhook bridge: hide the
40
+ // `x-powered-by` banner, honour `BRIDGE_TRUST_PROXY`, and parse the body
41
+ // as raw text so the HMAC signature can be verified before JSON parsing.
42
+ // `bodyLimit` overrides the body-size cap (default: Express's 100kb) for
43
+ // platforms that send larger payloads.
44
+ export function createWebhookApp(opts = {}) {
45
+ const app = express();
46
+ app.disable("x-powered-by");
47
+ configureTrustProxy(app);
48
+ app.use(express.text({ type: "application/json", limit: opts.bodyLimit }));
49
+ return app;
50
+ }
51
+ // Per-IP throttle for a webhook endpoint. CodeQL's
52
+ // `js/missing-rate-limiting` rule recognises `express-rate-limit`
53
+ // specifically; the default 120 req/min/IP cap sits well above any
54
+ // messaging platform's normal delivery rate and exists to bound a flood
55
+ // / stuck retry loop.
56
+ export function createWebhookRateLimit(limitPerMinute = 120) {
57
+ return rateLimit({
58
+ windowMs: 60_000,
59
+ limit: limitPerMinute,
60
+ standardHeaders: "draft-7",
61
+ legacyHeaders: false,
62
+ // Route through `ipKeyGenerator(...)` so IPv6 clients get folded to
63
+ // their /56 subnet — a raw `req.ip` key would let IPv6 rotation
64
+ // within a prefix evade the per-client limit. `req.ip` is
65
+ // trust-proxy-aware via `configureTrustProxy`. (Codex reviews on #1326.)
66
+ keyGenerator: (req) => ipKeyGenerator(req.ip ?? "", 56),
67
+ });
68
+ }
69
+ // Timing-safe HMAC signature check. `algorithm` is the OpenSSL digest
70
+ // name (e.g. "SHA256"); `encoding` is how the platform encodes the
71
+ // signature it sends (LINE / LINE WORKS: base64; Meta: hex).
72
+ //
73
+ // The length guard compares BYTE lengths, not string lengths: a
74
+ // malformed non-ASCII signature can share `expected`'s JS string length
75
+ // while `Buffer.from()` yields more bytes, and `timingSafeEqual` throws
76
+ // on unequal-length buffers. Comparing the buffers keeps a bad signature
77
+ // a deterministic `false` (fail closed) instead of a thrown 500 — this
78
+ // is what the per-bridge `try/catch` wrappers used to guarantee.
79
+ export function verifyHmacSignature(body, signature, secret, algorithm = "SHA256", encoding = "base64") {
80
+ const expected = Buffer.from(crypto.createHmac(algorithm, secret).update(body).digest(encoding));
81
+ const provided = Buffer.from(signature);
82
+ if (expected.length !== provided.length)
83
+ return false;
84
+ return crypto.timingSafeEqual(expected, provided);
85
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@mulmobridge/webhook-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Shared HTTP-webhook plumbing (Express app, trust-proxy, rate limit, HMAC verify) for the MulmoClaude messaging bridges",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepack": "yarn build",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "tsx --test test/test_*.ts",
25
+ "lint": "eslint src test"
26
+ },
27
+ "license": "MIT",
28
+ "author": "Receptron Team",
29
+ "dependencies": {
30
+ "express": "^5.1.0",
31
+ "express-rate-limit": "^8.6.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/express": "^5.0.0",
35
+ "tsx": "^4.23.1",
36
+ "typescript": "^6.0.3"
37
+ }
38
+ }