From 920551ae70ced17561fbf2ac0ed7c98aa5ecd556 Mon Sep 17 00:00:00 2001 From: Bradley Priest Date: Fri, 30 Jan 2026 11:55:01 +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 external webhook signature verification (GitHub, Stripe, Linear, etc.). Changes: - hooks.ts: Add verifyAndParseWebhook() with custom/token auth paths - hooks-mapping.ts: Add HookVerifyAuthContext type, loadVerifyAuth() - server-http.ts: Use verifyAndParseWebhook() for cleaner auth flow - webhook.md: Document verifyAuth with GitHub example - Tests for verifyAndParseWebhook and loadVerifyAuth --- docs/automation/webhook.md | 85 +++++++++++++++++++++ src/gateway/hooks-mapping.test.ts | 72 +++++++++++++++++- src/gateway/hooks-mapping.ts | 55 ++++++++++++-- src/gateway/hooks.test.ts | 107 ++++++++++++++++++++++++++ src/gateway/hooks.ts | 122 ++++++++++++++++++++++++++---- src/gateway/server-http.ts | 54 +++++++------ 6 files changed, 451 insertions(+), 44 deletions(-) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 55da521f5..2714feb13 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -255,6 +255,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 + +The `verifyAuth` function receives a context with the raw body for signature verification: + +```typescript +type HookVerifyAuthContext = { + headers: Record; // Lowercase header names + url: URL; // Parsed request URL + path: string; // Subpath after /hooks/ + rawBody: Buffer; // Raw request body for signature verification +}; +``` + +```javascript +// hooks/github-transform.js +import { createHmac, timingSafeEqual } from "crypto"; + +// Runs BEFORE token auth - return true to allow, false to reject +export function verifyAuth(ctx) { + 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) { + const event = ctx.headers["x-github-event"]; + + // Format based on event type + if (event === "push") { + return { message: `Push to ${ctx.payload.repository?.full_name}: ${ctx.payload.head_commit?.message}` }; + } + if (event === "pull_request") { + return { message: `PR ${ctx.payload.action}: ${ctx.payload.pull_request?.title}` }; + } + + return { message: `GitHub ${event}: ${JSON.stringify(ctx.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.js +``` + +### Async verifyAuth + +`verifyAuth` can be async if needed: + +```javascript +export async function verifyAuth(ctx) { + // 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.test.ts b/src/gateway/hooks-mapping.test.ts index 8900ffd07..c761ed3b1 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js"; +import { applyHookMappings, loadVerifyAuth, resolveHookMappings } from "./hooks-mapping.js"; const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail"); @@ -166,3 +166,73 @@ describe("hooks mapping", () => { expect(result?.ok).toBe(false); }); }); + +describe("loadVerifyAuth", () => { + it("returns undefined when transform has no verifyAuth export", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-noauth-")); + const modPath = path.join(dir, "transform.mjs"); + fs.writeFileSync(modPath, "export default () => ({ message: 'test' });"); + + const mappings = resolveHookMappings({ + transformsDir: dir, + mappings: [ + { + match: { path: "github" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }); + + const result = await loadVerifyAuth(mappings[0]!.transform!); + expect(result).toBeUndefined(); + }); + + it("returns verifyAuth function when exported", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-auth-")); + const modPath = path.join(dir, "transform.mjs"); + fs.writeFileSync( + modPath, + ` + export function verifyAuth(ctx) { + return ctx.headers["x-secret"] === "valid"; + } + export default () => ({ message: 'test' }); + `, + ); + + const mappings = resolveHookMappings({ + transformsDir: dir, + mappings: [ + { + match: { path: "github" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }); + + const result = await loadVerifyAuth(mappings[0]!.transform!); + expect(result).toBeDefined(); + expect(typeof result).toBe("function"); + + // Test the verifyAuth function works + if (result) { + const valid = await result({ + headers: { "x-secret": "valid" }, + url: new URL("http://localhost/hooks/github"), + path: "github", + rawBody: Buffer.from("{}"), + }); + expect(valid).toBe(true); + + const invalid = await result({ + headers: { "x-secret": "wrong" }, + url: new URL("http://localhost/hooks/github"), + path: "github", + rawBody: Buffer.from("{}"), + }); + expect(invalid).toBe(false); + } + }); +}); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..9e03f2bb2 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"]; @@ -195,7 +218,7 @@ function normalizeHookMapping( }; } -function mappingMatches(mapping: HookMappingResolved, ctx: HookMappingContext) { +export function mappingMatches(mapping: HookMappingResolved, ctx: HookMappingContext) { if (mapping.matchPath) { if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false; } @@ -293,14 +316,34 @@ 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 transformFn = resolveTransformFn(mod, transform.exportName); + const verifyAuth = + typeof mod.verifyAuth === "function" ? (mod.verifyAuth as HookVerifyAuthFn) : undefined; + const result: CachedTransform = { transform: transformFn, verifyAuth }; + transformCache.set(transform.modulePath, result); + return result; +} + +async function loadTransform(transform: HookMappingTransformResolved): Promise { + const cached = await loadTransformModule(transform); + return cached.transform; +} + +/** + * Load a transform module and return its verifyAuth export, if any. + */ +export async function loadVerifyAuth( + transform: HookMappingTransformResolved, +): Promise { + const mod = await loadTransformModule(transform); + return mod.verifyAuth; } function resolveTransformFn(mod: Record, exportName?: string): HookTransformFn { diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index a943f00ab..fbf6061e3 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -1,4 +1,5 @@ import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { MoltbotConfig } from "../config/config.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; @@ -8,7 +9,10 @@ import { extractHookToken, normalizeAgentPayload, normalizeWakePayload, + parseJsonBody, + readRawBody, resolveHooksConfig, + verifyAndParseWebhook, } from "./hooks.js"; describe("gateway hooks helpers", () => { @@ -150,3 +154,106 @@ const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ( resolveAccount: () => ({}), }, }); + +describe("readRawBody and parseJsonBody", () => { + test("readRawBody reads stream into buffer", async () => { + const req = Readable.from([ + Buffer.from('{"foo":'), + Buffer.from('"bar"}'), + ]) as unknown as IncomingMessage; + const result = await readRawBody(req, 1024); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.toString()).toBe('{"foo":"bar"}'); + } + }); + + test("parseJsonBody parses valid JSON", () => { + const result = parseJsonBody(Buffer.from('{"hello":"world"}')); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({ hello: "world" }); + } + }); + + test("parseJsonBody returns empty object for empty buffer", () => { + const result = parseJsonBody(Buffer.from("")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({}); + } + }); + + test("parseJsonBody rejects invalid JSON", () => { + const result = parseJsonBody(Buffer.from("not json")); + expect(result.ok).toBe(false); + }); +}); + +describe("verifyAndParseWebhook", () => { + function createMockRequest(body: string, headers: Record = {}): IncomingMessage { + const readable = new Readable({ + read() { + this.push(Buffer.from(body)); + this.push(null); + }, + }); + return Object.assign(readable, { headers }) as unknown as IncomingMessage; + } + + test("verifyAndParseWebhook with valid token succeeds", async () => { + const req = createMockRequest('{"message":"hello"}', { + authorization: "Bearer secret123", + }); + const result = await verifyAndParseWebhook({ + req, + url: new URL("http://localhost/hooks/test"), + subPath: "test", + headers: { authorization: "Bearer secret123" }, + mappings: [], + expectedToken: "secret123", + maxBodyBytes: 1024, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.payload).toEqual({ message: "hello" }); + expect(result.tokenFromQuery).toBe(false); + } + }); + + test("verifyAndParseWebhook with invalid token fails", async () => { + const req = createMockRequest('{"message":"hello"}', { + authorization: "Bearer wrong", + }); + const result = await verifyAndParseWebhook({ + req, + url: new URL("http://localhost/hooks/test"), + subPath: "test", + headers: { authorization: "Bearer wrong" }, + mappings: [], + expectedToken: "secret123", + maxBodyBytes: 1024, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(401); + } + }); + + test("verifyAndParseWebhook with query token sets tokenFromQuery", async () => { + const req = createMockRequest('{"message":"hello"}', {}); + const result = await verifyAndParseWebhook({ + req, + url: new URL("http://localhost/hooks/test?token=secret123"), + subPath: "test", + headers: {}, + mappings: [], + expectedToken: "secret123", + maxBodyBytes: 1024, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.tokenFromQuery).toBe(true); + } + }); +}); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 1fc6d52f4..87e994ffe 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -4,7 +4,14 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MoltbotConfig } from "../config/config.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; -import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; +import { + type HookMappingContext, + type HookMappingResolved, + type HookVerifyAuthFn, + loadVerifyAuth, + mappingMatches, + resolveHookMappings, +} from "./hooks-mapping.js"; const DEFAULT_HOOKS_PATH = "/hooks"; const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; @@ -61,10 +68,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 +90,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 +100,103 @@ 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 type VerifyWebhookContext = { + req: IncomingMessage; + url: URL; + subPath: string; + headers: Record; + mappings: HookMappingResolved[]; + expectedToken: string; + maxBodyBytes: number; +}; + +export type VerifyWebhookResult = + | { ok: true; payload: Record; tokenFromQuery: boolean } + | { ok: false; status: number; error: string }; + +/** + * Verify webhook authentication and parse body. + * + * Reads the raw body first, finds the matching mapping (using full match + * including path and source), then uses custom verifyAuth or token auth. + */ +export async function verifyAndParseWebhook( + ctx: VerifyWebhookContext, +): Promise { + const { req, url, subPath, headers, mappings, expectedToken, maxBodyBytes } = ctx; + + // Read raw body upfront so we can match on source and verify signatures + const rawBody = await readRawBody(req, maxBodyBytes); + if (!rawBody.ok) { + const status = rawBody.error === "payload too large" ? 413 : 400; + return { ok: false, status, error: rawBody.error }; + } + + // Parse body for mapping match (source matching needs the payload) + const parsed = parseJsonBody(rawBody.value); + const payload = + parsed.ok && typeof parsed.value === "object" && parsed.value !== null + ? (parsed.value as Record) + : {}; + + // Find the first matching mapping using full match criteria (path + source) + const matchCtx: HookMappingContext = { payload, headers, url, path: subPath }; + const matched = mappings.find((m) => mappingMatches(m, matchCtx)); + + if (matched?.transform) { + const verifyAuth = await loadVerifyAuth(matched.transform); + if (verifyAuth) { + const authCtx = { headers, url, path: subPath, rawBody: rawBody.value }; + let authResult: boolean; + try { + authResult = await verifyAuth(authCtx); + } catch (err) { + return { ok: false, status: 401, error: `verifyAuth error: ${String(err)}` }; + } + if (!authResult) { + return { ok: false, status: 401, error: "Unauthorized" }; + } + return { ok: true, payload, tokenFromQuery: false }; + } + } + + // No custom auth — verify token + const { token, fromQuery } = extractHookToken(req, url); + if (!token || token !== expectedToken) { + return { ok: false, status: 401, error: "Unauthorized" }; + } + + if (!parsed.ok) { + return { ok: false, status: 400, error: parsed.error }; + } + + return { ok: true, payload, tokenFromQuery: fromQuery }; +} + 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..db84d3be7 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -15,16 +15,15 @@ import { handleSlackHttpRequest } from "../slack/http/index.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js"; import { - extractHookToken, getHookChannelError, type HookMessageChannel, type HooksConfigResolved, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, - readJsonBody, resolveHookChannel, resolveHookDeliver, + verifyAndParseWebhook, } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; @@ -76,21 +75,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,15 +91,39 @@ 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 }); + const headers = normalizeHookHeaders(req); + + // Verify authentication (custom verifyAuth or token) and parse body + const verified = await verifyAndParseWebhook({ + req, + url, + subPath, + headers, + mappings: hooksConfig.mappings, + expectedToken: hooksConfig.token, + maxBodyBytes: hooksConfig.maxBodyBytes, + }); + + if (!verified.ok) { + if (verified.status === 401) { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + } else { + sendJson(res, verified.status, { ok: false, error: verified.error }); + } return true; } - const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; - const headers = normalizeHookHeaders(req); + if (verified.tokenFromQuery) { + 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 payload = verified.payload; if (subPath === "wake") { const normalized = normalizeWakePayload(payload as Record);