From fa5fa726151d4f116075e04716a106b9ede68529 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 15:29:29 +0000 Subject: [PATCH] support provider-accurate X webhook signatures --- src/app.js | 29 +++++++++++++++++++++++------ src/lib/signature.js | 33 +++++++++++++++++++++++++-------- test/app.test.js | 29 ++++++++++++++++++++++++++++- test/signature.test.js | 18 +++++++++++++++++- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/app.js b/src/app.js index bcfa74a..c059d0b 100644 --- a/src/app.js +++ b/src/app.js @@ -414,12 +414,29 @@ function buildApp({ } async function handleXWebhook(headers, rawBody) { - const signature = headers["x-signature"]; - const isValid = verifySignature({ - payload: rawBody, - secret: config.xWebhookSecret, - signature, - }); + const twitterSignature = headers["x-twitter-webhooks-signature"]; + const legacySignature = headers["x-signature"]; + + let isValid = false; + if (twitterSignature) { + isValid = verifySignature({ + payload: rawBody, + secret: config.xWebhookSecret, + signature: twitterSignature, + encoding: "base64", + }); + } else if (legacySignature) { + const normalized = legacySignature.startsWith("sha256=") + ? legacySignature.slice("sha256=".length) + : legacySignature; + const looksLikeHex = /^[a-f0-9]+$/i.test(normalized); + isValid = verifySignature({ + payload: rawBody, + secret: config.xWebhookSecret, + signature: legacySignature, + encoding: looksLikeHex ? "hex" : "base64", + }); + } if (!isValid) { return json(401, { error: "invalid_signature" }); diff --git a/src/lib/signature.js b/src/lib/signature.js index 21f10d7..2199198 100644 --- a/src/lib/signature.js +++ b/src/lib/signature.js @@ -2,14 +2,28 @@ const crypto = require("node:crypto"); -function hmacSHA256Hex(payload, secret) { +function hmacSHA256(payload, secret, encoding = "hex") { return crypto .createHmac("sha256", secret) .update(payload, "utf8") - .digest("hex"); + .digest(encoding); } -function verifySignature({ payload, secret, signature }) { +function hmacSHA256Hex(payload, secret) { + return hmacSHA256(payload, secret, "hex"); +} + +function hmacSHA256Base64(payload, secret) { + return hmacSHA256(payload, secret, "base64"); +} + +function verifySignature({ + payload, + secret, + signature, + encoding = "hex", + prefix = "sha256=", +}) { if (!secret) { throw new Error("webhook_secret_required"); } @@ -18,14 +32,15 @@ function verifySignature({ payload, secret, signature }) { return false; } - const normalized = signature.startsWith("sha256=") - ? signature.slice("sha256=".length) + const normalized = prefix && signature.startsWith(prefix) + ? signature.slice(prefix.length) : signature; - const expected = hmacSHA256Hex(payload, secret); + const expected = hmacSHA256(payload, secret, encoding); + const binaryEncoding = encoding === "hex" ? "hex" : "base64"; - const expectedBuf = Buffer.from(expected, "hex"); - const givenBuf = Buffer.from(normalized, "hex"); + const expectedBuf = Buffer.from(expected, binaryEncoding); + const givenBuf = Buffer.from(normalized, binaryEncoding); if (expectedBuf.length !== givenBuf.length) { return false; @@ -35,6 +50,8 @@ function verifySignature({ payload, secret, signature }) { } module.exports = { + hmacSHA256, hmacSHA256Hex, + hmacSHA256Base64, verifySignature, }; diff --git a/test/app.test.js b/test/app.test.js index bff9b8c..72bda27 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -3,7 +3,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { buildApp } = require("../src/app"); -const { hmacSHA256Hex } = require("../src/lib/signature"); +const { hmacSHA256Hex, hmacSHA256Base64 } = require("../src/lib/signature"); function getTestCookieValue(cookieHeader, name) { const parts = String(cookieHeader || "").split(";").map((part) => part.trim()); @@ -698,6 +698,33 @@ test("X webhook invalid signature is rejected", async () => { assert.equal(response.status, 401); }); +test("X webhook accepts x-twitter-webhooks-signature header", async () => { + const app = createApp(); + await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt-twitter-sig" }, "polar-secret"); + + const payload = { + mentionPostId: "m-twitter-header", + callerUserId: "u1", + parentPost: { + id: "p1", + authorId: "author", + article: { id: "a1", title: "T", body: "body text" }, + }, + }; + const rawBody = JSON.stringify(payload); + const signature = hmacSHA256Base64(rawBody, "x-secret"); + + const response = await call(app, { + method: "POST", + path: "/api/webhooks/x", + headers: { "x-twitter-webhooks-signature": `sha256=${signature}` }, + body: rawBody, + }); + + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).status, "completed"); +}); + test("X webhook valid flow processes article", async () => { const app = createApp(); diff --git a/test/signature.test.js b/test/signature.test.js index 215177f..c3266d5 100644 --- a/test/signature.test.js +++ b/test/signature.test.js @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { hmacSHA256Hex, verifySignature } = require("../src/lib/signature"); +const { hmacSHA256Hex, hmacSHA256Base64, verifySignature } = require("../src/lib/signature"); test("verifies valid signature", () => { const payload = JSON.stringify({ hello: "world" }); @@ -31,3 +31,19 @@ test("rejects missing signature", () => { assert.equal(verifySignature({ payload, secret, signature: "" }), false); }); + +test("verifies valid base64 signature", () => { + const payload = JSON.stringify({ hello: "world" }); + const secret = "topsecret"; + const sig = hmacSHA256Base64(payload, secret); + + assert.equal( + verifySignature({ + payload, + secret, + signature: `sha256=${sig}`, + encoding: "base64", + }), + true, + ); +});