@mulmobridge/webhook-runtime 0.1.0 → 1.0.1

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/dist/index.d.ts CHANGED
@@ -7,3 +7,22 @@ export declare function createWebhookApp(opts?: {
7
7
  }): Express;
8
8
  export declare function createWebhookRateLimit(limitPerMinute?: number): RateLimitRequestHandler;
9
9
  export declare function verifyHmacSignature(body: string, signature: string, secret: string, algorithm?: string, encoding?: crypto.BinaryToTextEncoding): boolean;
10
+ export declare const SAFE_CHALLENGE_RE: RegExp;
11
+ /** The challenge string when `raw` matches the shape we'll echo, else
12
+ * `null`. Non-string query forms (`?hub.challenge[]=…`) return `null`
13
+ * rather than coercing to "", so callers can't compare a coerced empty. */
14
+ export declare function narrowChallenge(raw: unknown): string | null;
15
+ export interface MetaVerificationResult {
16
+ status: 200 | 403;
17
+ body: string;
18
+ verified: boolean;
19
+ }
20
+ export declare function metaVerificationResult(query: Record<string, unknown>, verifyToken: string): MetaVerificationResult;
21
+ export interface MetaWebhookVerificationOptions {
22
+ rateLimit: RateLimitRequestHandler;
23
+ verifyToken: string;
24
+ /** Log prefix, e.g. "messenger" / "whatsapp". */
25
+ label: string;
26
+ }
27
+ export declare function registerMetaWebhookVerification(app: Express, opts: MetaWebhookVerificationOptions): void;
28
+ export declare function verifyMetaHmacSignature(rawBody: string, signature: string, appSecret: string): boolean;
package/dist/index.js CHANGED
@@ -83,3 +83,49 @@ export function verifyHmacSignature(body, signature, secret, algorithm = "SHA256
83
83
  return false;
84
84
  return crypto.timingSafeEqual(expected, provided);
85
85
  }
86
+ // ── Meta (Messenger / WhatsApp) webhook verification ───────────
87
+ //
88
+ // Meta's GET handshake echoes back `hub.challenge`. Narrowing it to a
89
+ // known shape before it reaches `res.send()` is the CodeQL sanitiser that
90
+ // clears the `js/reflected-xss` alert (Codex review on #1328) and gives
91
+ // defence-in-depth. `[A-Za-z0-9_-]{1,256}` covers every observed base64url
92
+ // nonce (~32 chars); widen it HERE if Meta ever extends the format.
93
+ export const SAFE_CHALLENGE_RE = /^[A-Za-z0-9_-]{1,256}$/;
94
+ /** The challenge string when `raw` matches the shape we'll echo, else
95
+ * `null`. Non-string query forms (`?hub.challenge[]=…`) return `null`
96
+ * rather than coercing to "", so callers can't compare a coerced empty. */
97
+ export function narrowChallenge(raw) {
98
+ if (typeof raw !== "string")
99
+ return null;
100
+ if (!SAFE_CHALLENGE_RE.test(raw))
101
+ return null;
102
+ return raw;
103
+ }
104
+ // Pure decision for Meta's GET handshake: echo `hub.challenge` only when
105
+ // `hub.mode=subscribe`, `hub.verify_token` matches, and the challenge clears
106
+ // `narrowChallenge`. Split out from the Express handler so it can be tested
107
+ // without a request object. Messenger and WhatsApp share this byte-for-byte.
108
+ export function metaVerificationResult(query, verifyToken) {
109
+ const challenge = narrowChallenge(query["hub.challenge"]);
110
+ if (query["hub.mode"] === "subscribe" && query["hub.verify_token"] === verifyToken && challenge !== null) {
111
+ return { status: 200, body: challenge, verified: true };
112
+ }
113
+ return { status: 403, body: "Forbidden", verified: false };
114
+ }
115
+ // Register the shared Meta webhook-verification GET handler (Messenger,
116
+ // WhatsApp). Always `text/plain` — the CodeQL `js/reflected-xss` defence for
117
+ // the echoed challenge (see narrowChallenge + SAFE_CHALLENGE_RE above).
118
+ export function registerMetaWebhookVerification(app, opts) {
119
+ app.get("/webhook", opts.rateLimit, (req, res) => {
120
+ const result = metaVerificationResult(req.query, opts.verifyToken);
121
+ if (result.verified)
122
+ console.log(`[${opts.label}] webhook verified`);
123
+ res.status(result.status).type("text/plain").send(result.body);
124
+ });
125
+ }
126
+ // Meta prefixes the x-hub-signature-256 hex digest with `sha256=`; strip it
127
+ // before the timing-safe compare. Wraps the generic hex/SHA-256 HMAC check so
128
+ // the Messenger and WhatsApp bridges stop carrying identical copies.
129
+ export function verifyMetaHmacSignature(rawBody, signature, appSecret) {
130
+ return verifyHmacSignature(rawBody, signature.replace("sha256=", ""), appSecret, "sha256", "hex");
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/webhook-runtime",
3
- "version": "0.1.0",
3
+ "version": "1.0.1",
4
4
  "description": "Shared HTTP-webhook plumbing (Express app, trust-proxy, rate limit, HMAC verify) for the MulmoClaude messaging bridges",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",