From b7e39eef9093fa46a03003368998e328fb4ac8fb Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 12:32:08 -0400 Subject: [PATCH] fix: handle coinbase webhook post-verify failures gracefully --- api/examples/coinbase-webhook.js | 29 ++++++++++++++++++------ tests/api-coinbase-webhook.test.js | 36 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/api/examples/coinbase-webhook.js b/api/examples/coinbase-webhook.js index b840d9c..afc5a06 100644 --- a/api/examples/coinbase-webhook.js +++ b/api/examples/coinbase-webhook.js @@ -7,8 +7,16 @@ const seenReceipts = new Map(); function normalizeReceipt(event, signerId) { - const eventId = event?.id || event?.event_id || 'unknown'; - const eventType = event?.type || 'coinbase.unknown'; + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error('unsupported_event_shape'); + } + + const eventId = event.id || event.event_id; + const eventType = event.type; + if (!eventId || typeof eventId !== 'string' || !eventType || typeof eventType !== 'string') { + throw new Error('unsupported_event_shape'); + } + const txHash = event?.data?.transactionHash || event?.transactionHash || null; return { receipt_id: `rcpt:coinbase_cdp:${eventId}`, @@ -77,18 +85,25 @@ module.exports = async function handler(req, res) { const eventId = verified.event?.id || verified.event?.event_id; if (!eventId) { - return res.status(400).json({ ok: false, status: 'malformed_payload' }); + return res.status(400).json({ ok: false, status: 'normalization_failed' }); } if (seenReceipts.has(eventId)) { return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: true, receipt: seenReceipts.get(eventId) }); } - const unsignedReceipt = normalizeReceipt(verified.event, signingCfg.signerId); - const receipt = await signReceipt(unsignedReceipt, signingCfg); + try { + const unsignedReceipt = normalizeReceipt(verified.event, signingCfg.signerId); + const receipt = await signReceipt(unsignedReceipt, signingCfg); - seenReceipts.set(eventId, receipt); - return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: false, receipt }); + seenReceipts.set(eventId, receipt); + return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: false, receipt }); + } catch (error) { + if (error && error.message === 'unsupported_event_shape') { + return res.status(400).json({ ok: false, status: 'normalization_failed' }); + } + return res.status(503).json({ ok: false, status: 'signing_unavailable' }); + } }; module.exports._internal = { diff --git a/tests/api-coinbase-webhook.test.js b/tests/api-coinbase-webhook.test.js index 5857ba8..050d22f 100644 --- a/tests/api-coinbase-webhook.test.js +++ b/tests/api-coinbase-webhook.test.js @@ -113,6 +113,42 @@ test('missing signing env returns 503 after valid HMAC', async () => { assert.equal(res.body.status, 'signing_unavailable'); }); + +test('invalid base64 signing key after valid HMAC returns 503', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + process.env.RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.RECEIPT_SIGNING_KID = 'test-kid-bad'; + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = Buffer.from('not a pem', 'utf8').toString('base64'); + + const rawBody = JSON.stringify({ id: 'evt_bad_key', type: 'wallet.transaction' }); + const timestamp = Math.floor(Date.now() / 1000); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + + const res = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res); + assert.equal(res.statusCode, 503); + assert.equal(res.body.status, 'signing_unavailable'); +}); + +test('malformed event shape after valid HMAC returns 400 normalization_failed', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.CL_RECEIPT_SIGNING_KID = 'test-kid-shape'; + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + + const rawBody = JSON.stringify({ id: 'evt_bad_shape' }); + const timestamp = Math.floor(Date.now() / 1000); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + + const res = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'normalization_failed'); +}); + test('valid payload returns signed receipt and duplicate returns same receipt', async () => { process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth';