From e7c367ab3cb06d09c5a56673a8a29925c3b086be Mon Sep 17 00:00:00 2001 From: Bradley Priest Date: Thu, 29 Jan 2026 20:23:54 +1300 Subject: [PATCH] feat(hooks): support custom verifyAuth in transform modules Add ability for hook mapping transform modules to export a verifyAuth function for custom authentication (e.g., GitHub HMAC signatures). When a transform module exports verifyAuth, it runs BEFORE standard token auth. This enables: - GitHub webhook signature verification - Stripe webhook signature verification - Any external service with custom auth schemes Changes: - hooks.ts: Split readJsonBody into readRawBody + parseJsonBody - hooks-mapping.ts: Add HookVerifyAuthContext type, findMappingVerifyAuth() - server-http.ts: Check for custom auth before token auth - webhook.md: Document verifyAuth with GitHub example Closes: discussion about GitHub webhooks --- docs/automation/webhook.md | 85 ++++++++++++++++++++++++++++++++ src/gateway/hooks-mapping.ts | 68 ++++++++++++++++++++++++-- src/gateway/hooks.ts | 40 ++++++++++----- src/gateway/server-http.ts | 94 +++++++++++++++++++++++++++--------- 4 files changed, 246 insertions(+), 41 deletions(-) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 81565bd41..22d1f8acc 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -145,6 +145,91 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}' ``` +## Custom Authentication (verifyAuth) + +External webhooks (GitHub, Stripe, Linear, etc.) often use their own authentication +schemes (e.g., HMAC signatures). Instead of using the standard bearer token, you can +export a `verifyAuth` function from your transform module to handle custom auth. + +When a mapping's transform module exports `verifyAuth`, it runs **before** the standard +token check. If it returns `true`, the request is authorized; if `false`, a 401 is returned. + +### Example: GitHub Webhook Signature Verification + +```typescript +// hooks/github-transform.ts +import { createHmac, timingSafeEqual } from "crypto"; +import type { HookVerifyAuthContext, HookMappingContext } from "moltbot"; + +// Runs BEFORE token auth - return true to allow, false to reject +export function verifyAuth(ctx: HookVerifyAuthContext): boolean { + const signature = ctx.headers["x-hub-signature-256"]; + if (!signature) return false; + + const secret = process.env.GITHUB_WEBHOOK_SECRET!; + const expected = "sha256=" + createHmac("sha256", secret) + .update(ctx.rawBody) + .digest("hex"); + + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +// Transform the payload (runs after auth passes) +export default function transform(ctx: HookMappingContext) { + const event = ctx.headers["x-github-event"]; + const payload = ctx.payload as Record; + + // Format based on event type + if (event === "push") { + return { message: `Push to ${payload.repository?.full_name}: ${payload.head_commit?.message}` }; + } + if (event === "pull_request") { + return { message: `PR ${payload.action}: ${payload.pull_request?.title}` }; + } + + return { message: `GitHub ${event}: ${JSON.stringify(payload).slice(0, 200)}` }; +} +``` + +Config: +```yaml +hooks: + enabled: true + token: "regular-token" # Still needed for non-custom-auth hooks + mappings: + - id: github + match: + path: github + action: agent + name: GitHub + transform: + module: github-transform.ts +``` + +### HookVerifyAuthContext + +The `verifyAuth` function receives: + +- `headers`: Record of lowercase header names to values +- `url`: The parsed URL object +- `path`: The subpath (e.g., "github" for `/hooks/github`) +- `rawBody`: The raw request body as a Buffer (for signature computation) + +### Async verifyAuth + +`verifyAuth` can be async if needed: + +```typescript +export async function verifyAuth(ctx: HookVerifyAuthContext): Promise { + // async validation (e.g., checking against external service) + return await validateSignature(ctx.headers, ctx.rawBody); +} +``` + ## Security - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..0792e0108 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -77,7 +77,30 @@ const hookPresetMappings: Record = { ], }; -const transformCache = new Map(); +/** + * Context for custom auth verification in hook transforms. + * Similar to HookMappingContext but includes the raw body buffer + * for signature verification (e.g., GitHub HMAC signatures). + */ +export type HookVerifyAuthContext = { + headers: Record; + url: URL; + path: string; + rawBody: Buffer; +}; + +/** + * Custom auth verification function exported by transform modules. + * Return true to allow the request, false to reject with 401. + */ +export type HookVerifyAuthFn = (ctx: HookVerifyAuthContext) => boolean | Promise; + +type CachedTransform = { + transform: HookTransformFn; + verifyAuth?: HookVerifyAuthFn; +}; + +const transformCache = new Map(); type HookTransformResult = Partial<{ kind: HookAction["kind"]; @@ -293,14 +316,51 @@ function validateAction(action: HookAction): HookMappingResult { return { ok: true, action }; } -async function loadTransform(transform: HookMappingTransformResolved): Promise { +async function loadTransformModule( + transform: HookMappingTransformResolved, +): Promise { const cached = transformCache.get(transform.modulePath); if (cached) return cached; const url = pathToFileURL(transform.modulePath).href; const mod = (await import(url)) as Record; const fn = resolveTransformFn(mod, transform.exportName); - transformCache.set(transform.modulePath, fn); - return fn; + const verifyAuth = + typeof mod.verifyAuth === "function" ? (mod.verifyAuth as HookVerifyAuthFn) : undefined; + const result: CachedTransform = { transform: fn, verifyAuth }; + transformCache.set(transform.modulePath, result); + return result; +} + +async function loadTransform(transform: HookMappingTransformResolved): Promise { + const cached = await loadTransformModule(transform); + return cached.transform; +} + +/** + * Find the first mapping that matches the given path and has a custom verifyAuth function. + * Returns the verifyAuth function if found, null otherwise. + * + * This is called BEFORE normal token auth to allow mappings to implement + * custom authentication (e.g., GitHub webhook HMAC signatures). + */ +export async function findMappingVerifyAuth( + mappings: HookMappingResolved[], + subPath: string, +): Promise { + const normalizedPath = normalizeMatchPath(subPath); + for (const mapping of mappings) { + // Only match on path for pre-auth check (no payload available yet) + if (mapping.matchPath && mapping.matchPath !== normalizedPath) continue; + if (!mapping.matchPath && !mapping.matchSource) continue; // needs at least one matcher + + if (mapping.transform) { + const mod = await loadTransformModule(mapping.transform); + if (mod.verifyAuth) { + return mod.verifyAuth; + } + } + } + return null; } function resolveTransformFn(mod: Record, exportName?: string): HookTransformFn { diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 1fc6d52f4..c27019c6a 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -61,10 +61,10 @@ export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResul return { token: undefined, fromQuery: false }; } -export async function readJsonBody( +export async function readRawBody( req: IncomingMessage, maxBytes: number, -): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { +): Promise<{ ok: true; value: Buffer } | { ok: false; error: string }> { return await new Promise((resolve) => { let done = false; let total = 0; @@ -83,17 +83,7 @@ export async function readJsonBody( req.on("end", () => { if (done) return; done = true; - const raw = Buffer.concat(chunks).toString("utf-8").trim(); - if (!raw) { - resolve({ ok: true, value: {} }); - return; - } - try { - const parsed = JSON.parse(raw) as unknown; - resolve({ ok: true, value: parsed }); - } catch (err) { - resolve({ ok: false, error: String(err) }); - } + resolve({ ok: true, value: Buffer.concat(chunks) }); }); req.on("error", (err) => { if (done) return; @@ -103,6 +93,30 @@ export async function readJsonBody( }); } +export function parseJsonBody( + raw: Buffer, +): { ok: true; value: unknown } | { ok: false; error: string } { + const str = raw.toString("utf-8").trim(); + if (!str) { + return { ok: true, value: {} }; + } + try { + const parsed = JSON.parse(str) as unknown; + return { ok: true, value: parsed }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +export async function readJsonBody( + req: IncomingMessage, + maxBytes: number, +): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { + const raw = await readRawBody(req, maxBytes); + if (!raw.ok) return raw; + return parseJsonBody(raw.value); +} + export function normalizeHookHeaders(req: IncomingMessage) { const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f08dc811c..807c99f06 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -22,11 +22,13 @@ import { normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, + parseJsonBody, readJsonBody, + readRawBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; -import { applyHookMappings } from "./hooks-mapping.js"; +import { applyHookMappings, findMappingVerifyAuth } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -76,21 +78,6 @@ export function createHooksRequestHandler( return false; } - const { token, fromQuery } = extractHookToken(req, url); - if (!token || token !== hooksConfig.token) { - res.statusCode = 401; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Unauthorized"); - return true; - } - if (fromQuery) { - logHooks.warn( - "Hook token provided via query parameter is deprecated for security reasons. " + - "Tokens in URLs appear in logs, browser history, and referrer headers. " + - "Use Authorization: Bearer or X-Moltbot-Token header instead.", - ); - } - if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); @@ -107,16 +94,75 @@ export function createHooksRequestHandler( return true; } - const body = await readJsonBody(req, hooksConfig.maxBodyBytes); - if (!body.ok) { - const status = body.error === "payload too large" ? 413 : 400; - sendJson(res, status, { ok: false, error: body.error }); - return true; - } - - const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; const headers = normalizeHookHeaders(req); + // Check for custom auth via mapping transform's verifyAuth export + // This runs BEFORE normal token auth to support external webhook signatures (e.g., GitHub HMAC) + let payload: Record = {}; + const customVerifyAuth = await findMappingVerifyAuth(hooksConfig.mappings, subPath); + + if (customVerifyAuth) { + // Read raw body for signature verification + const rawBody = await readRawBody(req, hooksConfig.maxBodyBytes); + if (!rawBody.ok) { + const status = rawBody.error === "payload too large" ? 413 : 400; + sendJson(res, status, { ok: false, error: rawBody.error }); + return true; + } + + // Call custom verifyAuth function + const authCtx = { headers, url, path: subPath, rawBody: rawBody.value }; + let authResult: boolean; + try { + authResult = await customVerifyAuth(authCtx); + } catch (err) { + logHooks.warn(`custom verifyAuth failed: ${String(err)}`); + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + return true; + } + + if (!authResult) { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + return true; + } + + // Auth passed, parse the JSON body from the raw buffer + const parsed = parseJsonBody(rawBody.value); + if (!parsed.ok) { + sendJson(res, 400, { ok: false, error: parsed.error }); + return true; + } + payload = typeof parsed.value === "object" && parsed.value !== null ? parsed.value : {}; + } else { + // Normal token auth + const { token, fromQuery } = extractHookToken(req, url); + if (!token || token !== hooksConfig.token) { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + return true; + } + if (fromQuery) { + logHooks.warn( + "Hook token provided via query parameter is deprecated for security reasons. " + + "Tokens in URLs appear in logs, browser history, and referrer headers. " + + "Use Authorization: Bearer or X-Moltbot-Token header instead.", + ); + } + + const body = await readJsonBody(req, hooksConfig.maxBodyBytes); + if (!body.ok) { + const status = body.error === "payload too large" ? 413 : 400; + sendJson(res, status, { ok: false, error: body.error }); + return true; + } + payload = typeof body.value === "object" && body.value !== null ? body.value : {}; + } + if (subPath === "wake") { const normalized = normalizeWakePayload(payload as Record); if (!normalized.ok) {