Skip to content

Commit 1d8d1ce

Browse files
Merge pull request #314 from adcontextprotocol/EmmaLouise2018/fix-issue-313
fix: use raw body bytes for webhook HMAC verification
2 parents 7e2d999 + a86795a commit 1d8d1ce

6 files changed

Lines changed: 282 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@adcp/client": patch
3+
---
4+
5+
Fix webhook HMAC signature verification to use raw HTTP body bytes instead of re-serialized JSON. `verifyWebhookSignature()` now accepts a raw body string (preferred) or parsed object (backward compat). This fixes cross-language interop where different JSON serializers produce different byte representations.

src/lib/core/ADCPMultiAgentClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,8 @@ export class ADCPMultiAgentClient {
933933
taskType: string,
934934
operationId: string,
935935
signature?: string,
936-
timestamp?: string | number
936+
timestamp?: string | number,
937+
rawBody?: string
937938
): Promise<boolean> {
938939
// Extract agent ID from payload
939940
// Webhook payloads include agent_id or we can infer from operation_id pattern
@@ -944,7 +945,7 @@ export class ADCPMultiAgentClient {
944945
}
945946

946947
const agent = this.getAgent(agentId);
947-
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp);
948+
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
948949
}
949950

950951
/**

src/lib/core/AgentClient.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ export class AgentClient {
8787
taskType: string,
8888
operationId: string,
8989
signature?: string,
90-
timestamp?: string | number
90+
timestamp?: string | number,
91+
rawBody?: string
9192
): Promise<boolean> {
92-
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp);
93+
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
9394
}
9495

9596
/**
@@ -104,15 +105,19 @@ export class AgentClient {
104105
}
105106

106107
/**
107-
* Verify webhook signature using HMAC-SHA256 per AdCP PR #86 spec
108+
* Verify webhook signature using HMAC-SHA256 per AdCP spec.
108109
*
109-
* @param payload - Webhook payload object
110+
* Prefer passing the raw HTTP body string for correct cross-language interop.
111+
* Passing a parsed object still works but re-serializes with JSON.stringify,
112+
* which may not match the sender's byte representation.
113+
*
114+
* @param rawBodyOrPayload - Raw HTTP body string (preferred) or parsed payload object (deprecated)
110115
* @param signature - X-ADCP-Signature header value (format: "sha256=...")
111116
* @param timestamp - X-ADCP-Timestamp header value (Unix timestamp)
112117
* @returns true if signature is valid
113118
*/
114-
verifyWebhookSignature(payload: any, signature: string, timestamp: string | number): boolean {
115-
return this.client.verifyWebhookSignature(payload, signature, timestamp);
119+
verifyWebhookSignature(rawBodyOrPayload: string | any, signature: string, timestamp: string | number): boolean {
120+
return this.client.verifyWebhookSignature(rawBodyOrPayload, signature, timestamp);
116121
}
117122

118123
// ====== MEDIA BUY TASKS ======

src/lib/core/SingleAgentClient.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -566,15 +566,16 @@ export class SingleAgentClient {
566566
taskType: string,
567567
operationId: string,
568568
signature?: string,
569-
timestamp?: string | number
569+
timestamp?: string | number,
570+
rawBody?: string
570571
): Promise<boolean> {
571572
// Verify signature if secret is configured
572573
if (this.config.webhookSecret) {
573574
if (!signature || !timestamp) {
574575
throw new Error('Webhook signature and timestamp required but not provided');
575576
}
576577

577-
const isValid = this.verifyWebhookSignature(payload, signature, timestamp);
578+
const isValid = this.verifyWebhookSignature(rawBody ?? payload, signature, timestamp);
578579
if (!isValid) {
579580
throw new Error('Invalid webhook signature or timestamp too old');
580581
}
@@ -783,11 +784,16 @@ export class SingleAgentClient {
783784
const signature = req.headers['x-adcp-signature'] || req.headers['X-ADCP-Signature'];
784785
const timestamp = req.headers['x-adcp-timestamp'] || req.headers['X-ADCP-Timestamp'];
785786

786-
// Parse body if needed
787+
// Capture raw body for signature verification, then parse
788+
const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
787789
const payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
788790

789-
// Handle webhook with automatic verification
790-
const handled = await this.handleWebhook(payload, signature, timestamp);
791+
// Extract routing params if available (e.g., Express route params)
792+
const taskType = req.params?.task_type || req.params?.taskType || 'unknown';
793+
const operationId = req.params?.operation_id || req.params?.operationId || 'unknown';
794+
795+
// Handle webhook with automatic verification using raw body bytes
796+
const handled = await this.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
791797

792798
// Return success
793799
if (res.json) {
@@ -811,17 +817,26 @@ export class SingleAgentClient {
811817
}
812818

813819
/**
814-
* Verify webhook signature using HMAC-SHA256 per AdCP PR #86 spec
820+
* Verify webhook signature using HMAC-SHA256 per AdCP spec.
821+
*
822+
* HMAC is computed over the **raw HTTP body bytes** — the exact bytes received
823+
* on the wire, before JSON parsing. This ensures cross-language interop since
824+
* different JSON serializers may produce different byte representations of the
825+
* same logical payload.
826+
*
827+
* For backward compatibility, a parsed object is still accepted but will be
828+
* re-serialized with JSON.stringify, which may not match the sender's bytes.
829+
* Always prefer passing the raw body string.
815830
*
816831
* Signature format: sha256={hex_signature}
817-
* Message format: {timestamp}.{json_payload}
832+
* Message format: {timestamp}.{raw_body}
818833
*
819-
* @param payload - Webhook payload object
834+
* @param rawBodyOrPayload - Raw HTTP body string (preferred) or parsed payload object (deprecated)
820835
* @param signature - X-ADCP-Signature header value (format: "sha256=...")
821836
* @param timestamp - X-ADCP-Timestamp header value (Unix timestamp)
822837
* @returns true if signature is valid
823838
*/
824-
verifyWebhookSignature(payload: any, signature: string, timestamp: string | number): boolean {
839+
verifyWebhookSignature(rawBodyOrPayload: string | any, signature: string, timestamp: string | number): boolean {
825840
if (!this.config.webhookSecret) {
826841
return false;
827842
}
@@ -834,8 +849,11 @@ export class SingleAgentClient {
834849
return false; // Request too old or from future
835850
}
836851

837-
// Build message per AdCP spec: {timestamp}.{json_payload}
838-
const message = `${ts}.${JSON.stringify(payload)}`;
852+
// Use raw body bytes when available; fall back to JSON.stringify for backward compat
853+
const body = typeof rawBodyOrPayload === 'string' ? rawBodyOrPayload : JSON.stringify(rawBodyOrPayload);
854+
855+
// Build message per AdCP spec: {timestamp}.{raw_body}
856+
const message = `${ts}.${body}`;
839857

840858
// Calculate expected signature
841859
const hmac = crypto.createHmac('sha256', this.config.webhookSecret);

src/server/server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Fastify, { FastifyInstance } from 'fastify';
33
import fastifyStatic from '@fastify/static';
44
import fastifyCors from '@fastify/cors';
55
import path from 'path';
6+
import { Readable } from 'stream';
67
import {
78
ADCPMultiAgentClient,
89
ConfigurationManager,
@@ -61,6 +62,23 @@ const app: FastifyInstance = Fastify({
6162
},
6263
});
6364

65+
// Capture raw body on webhook routes for HMAC signature verification.
66+
// The raw bytes must be used (not re-serialized JSON) for cross-language interop.
67+
app.addHook('preParsing', async (request, _reply, payload) => {
68+
if (request.url.startsWith('/webhook/')) {
69+
const chunks: Buffer[] = [];
70+
for await (const chunk of payload) {
71+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
72+
}
73+
const rawBody = Buffer.concat(chunks).toString('utf8');
74+
(request as any).rawBody = rawBody;
75+
76+
// Return a new readable stream so Fastify can still parse the body
77+
return Readable.from([rawBody]);
78+
}
79+
return payload;
80+
});
81+
6482
// Initialize ADCP client with configured agents
6583
const configuredAgents = ConfigurationManager.loadAgentsFromEnv();
6684

@@ -2033,6 +2051,9 @@ app.post<{
20332051
const signature = request.headers['x-adcp-signature'] as string | undefined;
20342052
const timestamp = request.headers['x-adcp-timestamp'] as string | undefined;
20352053

2054+
// Use raw body for HMAC verification when available, fall back to re-serialized payload
2055+
const rawBody = (request as any).rawBody as string | undefined;
2056+
20362057
app.log.info(`Webhook received: ${taskType} for operation ${operationId}`);
20372058

20382059
// TODO: Validate payload against JSON schema for task_type
@@ -2052,7 +2073,7 @@ app.post<{
20522073
// The webhook URL was generated with this agent_id during operation setup
20532074
// We use it to look up the correct agent configuration (auth, protocol, etc)
20542075
const agent = adcpClient.agent(agentId);
2055-
const handled = await agent.handleWebhook(payload, taskType, operationId, signature, timestamp);
2076+
const handled = await agent.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
20562077

20572078
if (!handled) {
20582079
app.log.warn(`Webhook not handled - no handlers configured`);

0 commit comments

Comments
 (0)