Files
accounted/lib/email/standard-webhook.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

79 lines
2.7 KiB
TypeScript

/**
* Standard Webhooks signature verification (https://www.standardwebhooks.com/),
* implemented with node:crypto only: the Supabase Send Email hook signs its
* requests in this format and the endpoint must stay dependency-free.
*
* Scheme: the secret is base64 material, presented as "v1,whsec_<base64>"
* (Supabase dashboard) or "whsec_<base64>". The signed content is
* "<msg-id>.<timestamp>.<raw-body>", HMAC-SHA256 over the decoded secret,
* base64-encoded. The webhook-signature header carries a space-separated
* list of "<version>,<signature>" entries; only symmetric v1 entries are
* considered.
*/
import { createHmac, timingSafeEqual } from 'node:crypto'
const DEFAULT_TOLERANCE_SECONDS = 300
export interface StandardWebhookVerificationInput {
/** Raw secret ("v1,whsec_...", "whsec_..." or bare base64). */
secret: string
/** Raw request body, byte-identical to what was signed. */
payload: string
/** webhook-id header. */
id: string | null
/** webhook-timestamp header (unix seconds). */
timestamp: string | null
/** webhook-signature header. */
signature: string | null
/** Replay window in seconds; default 300. */
toleranceSeconds?: number
/** Test hook: current time in ms. */
nowMs?: number
}
function decodeSecret(secret: string): Buffer {
let s = secret.trim()
if (s.startsWith('v1,')) s = s.slice(3)
if (s.startsWith('whsec_')) s = s.slice(6)
return Buffer.from(s, 'base64')
}
/**
* True only when the signature verifies and the timestamp is within the
* replay tolerance. Never throws: any malformed input is a failed
* verification.
*/
export function verifyStandardWebhookSignature(
input: StandardWebhookVerificationInput,
): boolean {
const { secret, payload, id, timestamp, signature } = input
if (!secret || !id || !timestamp || !signature) return false
const ts = Number(timestamp)
if (!Number.isFinite(ts)) return false
const nowSeconds = (input.nowMs ?? Date.now()) / 1000
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
if (Math.abs(nowSeconds - ts) > tolerance) return false
const key = decodeSecret(secret)
if (key.length === 0) return false
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.${payload}`)
.digest()
for (const part of signature.split(/\s+/)) {
const commaIndex = part.indexOf(',')
if (commaIndex === -1) continue
const version = part.slice(0, commaIndex)
const candidateBase64 = part.slice(commaIndex + 1)
if (version !== 'v1' || !candidateBase64) continue
const candidate = Buffer.from(candidateBase64, 'base64')
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
return true
}
}
return false
}