support provider-accurate X webhook signatures

This commit is contained in:
Codex
2026-02-18 15:29:29 +00:00
parent f672677d4f
commit fa5fa72615
4 changed files with 93 additions and 16 deletions

View File

@@ -414,12 +414,29 @@ function buildApp({
} }
async function handleXWebhook(headers, rawBody) { async function handleXWebhook(headers, rawBody) {
const signature = headers["x-signature"]; const twitterSignature = headers["x-twitter-webhooks-signature"];
const isValid = verifySignature({ const legacySignature = headers["x-signature"];
payload: rawBody,
secret: config.xWebhookSecret, let isValid = false;
signature, 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) { if (!isValid) {
return json(401, { error: "invalid_signature" }); return json(401, { error: "invalid_signature" });

View File

@@ -2,14 +2,28 @@
const crypto = require("node:crypto"); const crypto = require("node:crypto");
function hmacSHA256Hex(payload, secret) { function hmacSHA256(payload, secret, encoding = "hex") {
return crypto return crypto
.createHmac("sha256", secret) .createHmac("sha256", secret)
.update(payload, "utf8") .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) { if (!secret) {
throw new Error("webhook_secret_required"); throw new Error("webhook_secret_required");
} }
@@ -18,14 +32,15 @@ function verifySignature({ payload, secret, signature }) {
return false; return false;
} }
const normalized = signature.startsWith("sha256=") const normalized = prefix && signature.startsWith(prefix)
? signature.slice("sha256=".length) ? signature.slice(prefix.length)
: signature; : 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 expectedBuf = Buffer.from(expected, binaryEncoding);
const givenBuf = Buffer.from(normalized, "hex"); const givenBuf = Buffer.from(normalized, binaryEncoding);
if (expectedBuf.length !== givenBuf.length) { if (expectedBuf.length !== givenBuf.length) {
return false; return false;
@@ -35,6 +50,8 @@ function verifySignature({ payload, secret, signature }) {
} }
module.exports = { module.exports = {
hmacSHA256,
hmacSHA256Hex, hmacSHA256Hex,
hmacSHA256Base64,
verifySignature, verifySignature,
}; };

View File

@@ -3,7 +3,7 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { buildApp } = require("../src/app"); const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature"); const { hmacSHA256Hex, hmacSHA256Base64 } = require("../src/lib/signature");
function getTestCookieValue(cookieHeader, name) { function getTestCookieValue(cookieHeader, name) {
const parts = String(cookieHeader || "").split(";").map((part) => part.trim()); 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); 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 () => { test("X webhook valid flow processes article", async () => {
const app = createApp(); const app = createApp();

View File

@@ -2,7 +2,7 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); 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", () => { test("verifies valid signature", () => {
const payload = JSON.stringify({ hello: "world" }); const payload = JSON.stringify({ hello: "world" });
@@ -31,3 +31,19 @@ test("rejects missing signature", () => {
assert.equal(verifySignature({ payload, secret, signature: "" }), false); 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,
);
});