test: cover webhook signature validation
This commit is contained in:
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
|
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Tightened standard webhook signature validation to reject invalid timestamps and non-v1 signature schemes.
|
||||||
|
|
||||||
## [0.7.0] - 2026-07-19
|
## [0.7.0] - 2026-07-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
|
|||||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
}
|
}
|
||||||
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
|
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
|
||||||
if (!id || !timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
|
const seconds = Number(timestamp); if (!id || !timestamp || !signature || !Number.isFinite(seconds) || !Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false;
|
||||||
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
||||||
return signature.split(" ").some((item) => { const value = item.split(",")[1]; if (!value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
|
return signature.split(" ").some((item) => { const [version, value] = item.split(","); if (version !== "v1" || !value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { standardWebhookMatches, webhookSecretHash, webhookSecretMatches } from "../lib/webhook";
|
||||||
|
|
||||||
|
test("verifies path secrets using a constant-time hash comparison", () => {
|
||||||
|
const secret = "path-secret"; assert.equal(webhookSecretMatches(secret, webhookSecretHash(secret)), true); assert.equal(webhookSecretMatches("wrong", webhookSecretHash(secret)), false); assert.equal(webhookSecretMatches(secret, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts only fresh v1 standard webhooks with an exact body signature", () => {
|
||||||
|
const secret = "signing-secret", id = "event-1", timestamp = String(Math.floor(Date.now() / 1000)), body = '{"type":"memo.updated"}';
|
||||||
|
const signature = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
||||||
|
assert.equal(standardWebhookMatches(secret, id, timestamp, `v1,${signature}`, body), true);
|
||||||
|
assert.equal(standardWebhookMatches(secret, id, timestamp, `v1,${signature}`, "{}"), false);
|
||||||
|
assert.equal(standardWebhookMatches(secret, id, "not-a-time", `v1,${signature}`, body), false);
|
||||||
|
assert.equal(standardWebhookMatches(secret, id, timestamp, `v2,${signature}`, body), false);
|
||||||
|
assert.equal(standardWebhookMatches(secret, id, String(Math.floor(Date.now() / 1000) - 301), `v1,${signature}`, body), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user