support provider-accurate X webhook signatures
This commit is contained in:
29
src/app.js
29
src/app.js
@@ -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" });
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user