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
This commit is contained in:
parent
6b82fabdbe
commit
920551ae70
@ -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"}]}'
|
-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<string, string>; // 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
|
## Security
|
||||||
|
|
||||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
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");
|
const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail");
|
||||||
|
|
||||||
@ -166,3 +166,73 @@ describe("hooks mapping", () => {
|
|||||||
expect(result?.ok).toBe(false);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -77,7 +77,30 @@ const hookPresetMappings: Record<string, HookMappingConfig[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformCache = new Map<string, HookTransformFn>();
|
/**
|
||||||
|
* 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<string, string>;
|
||||||
|
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<boolean>;
|
||||||
|
|
||||||
|
type CachedTransform = {
|
||||||
|
transform: HookTransformFn;
|
||||||
|
verifyAuth?: HookVerifyAuthFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformCache = new Map<string, CachedTransform>();
|
||||||
|
|
||||||
type HookTransformResult = Partial<{
|
type HookTransformResult = Partial<{
|
||||||
kind: HookAction["kind"];
|
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) {
|
||||||
if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false;
|
if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false;
|
||||||
}
|
}
|
||||||
@ -293,14 +316,34 @@ function validateAction(action: HookAction): HookMappingResult {
|
|||||||
return { ok: true, action };
|
return { ok: true, action };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
|
async function loadTransformModule(
|
||||||
|
transform: HookMappingTransformResolved,
|
||||||
|
): Promise<CachedTransform> {
|
||||||
const cached = transformCache.get(transform.modulePath);
|
const cached = transformCache.get(transform.modulePath);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const url = pathToFileURL(transform.modulePath).href;
|
const url = pathToFileURL(transform.modulePath).href;
|
||||||
const mod = (await import(url)) as Record<string, unknown>;
|
const mod = (await import(url)) as Record<string, unknown>;
|
||||||
const fn = resolveTransformFn(mod, transform.exportName);
|
const transformFn = resolveTransformFn(mod, transform.exportName);
|
||||||
transformCache.set(transform.modulePath, fn);
|
const verifyAuth =
|
||||||
return fn;
|
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<HookTransformFn> {
|
||||||
|
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<HookVerifyAuthFn | undefined> {
|
||||||
|
const mod = await loadTransformModule(transform);
|
||||||
|
return mod.verifyAuth;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {
|
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||||
@ -8,7 +9,10 @@ import {
|
|||||||
extractHookToken,
|
extractHookToken,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
|
parseJsonBody,
|
||||||
|
readRawBody,
|
||||||
resolveHooksConfig,
|
resolveHooksConfig,
|
||||||
|
verifyAndParseWebhook,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
|
|
||||||
describe("gateway hooks helpers", () => {
|
describe("gateway hooks helpers", () => {
|
||||||
@ -150,3 +154,106 @@ const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => (
|
|||||||
resolveAccount: () => ({}),
|
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<string, string> = {}): 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -4,7 +4,14 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import { normalizeMessageChannel } from "../utils/message-channel.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_PATH = "/hooks";
|
||||||
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
|
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 };
|
return { token: undefined, fromQuery: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonBody(
|
export async function readRawBody(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
maxBytes: number,
|
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) => {
|
return await new Promise((resolve) => {
|
||||||
let done = false;
|
let done = false;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@ -83,17 +90,7 @@ export async function readJsonBody(
|
|||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
resolve({ ok: true, value: Buffer.concat(chunks) });
|
||||||
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) });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
req.on("error", (err) => {
|
req.on("error", (err) => {
|
||||||
if (done) return;
|
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<string, string>;
|
||||||
|
mappings: HookMappingResolved[];
|
||||||
|
expectedToken: string;
|
||||||
|
maxBodyBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyWebhookResult =
|
||||||
|
| { ok: true; payload: Record<string, unknown>; 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<VerifyWebhookResult> {
|
||||||
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// 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) {
|
export function normalizeHookHeaders(req: IncomingMessage) {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
|||||||
@ -15,16 +15,15 @@ import { handleSlackHttpRequest } from "../slack/http/index.js";
|
|||||||
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||||
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
extractHookToken,
|
|
||||||
getHookChannelError,
|
getHookChannelError,
|
||||||
type HookMessageChannel,
|
type HookMessageChannel,
|
||||||
type HooksConfigResolved,
|
type HooksConfigResolved,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
normalizeHookHeaders,
|
normalizeHookHeaders,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
readJsonBody,
|
|
||||||
resolveHookChannel,
|
resolveHookChannel,
|
||||||
resolveHookDeliver,
|
resolveHookDeliver,
|
||||||
|
verifyAndParseWebhook,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { applyHookMappings } from "./hooks-mapping.js";
|
import { applyHookMappings } from "./hooks-mapping.js";
|
||||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
@ -76,21 +75,6 @@ export function createHooksRequestHandler(
|
|||||||
return false;
|
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 <token> or X-Moltbot-Token header instead.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
res.setHeader("Allow", "POST");
|
res.setHeader("Allow", "POST");
|
||||||
@ -107,15 +91,39 @@ export function createHooksRequestHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
|
const headers = normalizeHookHeaders(req);
|
||||||
if (!body.ok) {
|
|
||||||
const status = body.error === "payload too large" ? 413 : 400;
|
// Verify authentication (custom verifyAuth or token) and parse body
|
||||||
sendJson(res, status, { ok: false, error: body.error });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
|
if (verified.tokenFromQuery) {
|
||||||
const headers = normalizeHookHeaders(req);
|
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 <token> or X-Moltbot-Token header instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verified.payload;
|
||||||
|
|
||||||
if (subPath === "wake") {
|
if (subPath === "wake") {
|
||||||
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user