How to handle signed requests from Web Hooks.
Because your webhook URL needs to be public to accept requests from Tidy, it is at risk of receiving fraudulent data from a malicious actor.
To counter this, each webhook request from Tidy includes a Tidy-Signature
header that you can use alongside your webhook's unique signing key to verify that the contents of the request are unmodified and authentic.
A Tidy-Webhook-ID
header is also included, which uniquely identifies the specific webhook if you have multiple sending to the same URL, this code is present on the list of webhooks page. You can use this header to decide which signing key to use to verify the message, but if verification succeeds, you should also check that the Tidy-Webhook-ID
matches the webhook_id attribute in the message body.
The contents of the header currently look like this: t=unixtime,v1=hexsignature
.
Make sure you parse out the t
and v1
elements using a method that is tolerant for different key orderings and new uniquely-keyed elements to also be included, to ensure that future additions to the header do not break your code. i.e. split them by the ,
character, then the =
character instead of relying on any fixed-length parsing or brittle order-dependent regexes.
As an example, to verify a signature with a five minute grace period would look like so:
require 'base64'
require 'openssl'
require 'json'
tolerance = 300 # 5 minutes
recieved_via_http_method = 'POST'
webhook_id_on_record = 'ff434f3g4t4y2'
payload = '{"message":"my webhook message"}'
header = 't=1677726570,v1=d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
secret = Base64.strict_decode64(
'eIEEPEueMuEIz9rzNAL+hbJY6+KmbKkfowaYxcCO7ikWyysBXEnq1YBVF9AzIKWjvCzFVTQ33wWW3HeTZKoONA==')
# ['t=1677726570', 'v1=d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d']
signature_elements = header.split(',')
# [['t', '1677726570'], ['v1', 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d']]
element_pairs = signature_elements.map { |e| e.split('=') }
# {'t'=>'1677726570', 'v1'=>'d8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'}
elements = element_pairs.to_h
timestamp = elements['t'].to_i # 1677726570
# 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
signature = elements['v1']
# '1677726570.{"message":"my webhook message"}'
timestamped_payload = "#{timestamp}.#{payload}"
# 'd8ddb065d5ff7f74274c22161a8c45a1bd192ac4e97b92d0ce76a29af71b271d'
expected_sig = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'), secret, timestamped_payload)
if signature != expected_sig # false
raise 'INVALID SIGNATURE'
end
if timestamp < Time.now.to_i - tolerance # false
raise 'MESSAGE IS TOO OLD'
end
data = JSON.parse(payload) # decode the payload now its verified
if data['webhook_id'] != webhook_id_on_record
|| data['http_method'] != recieved_via_http_method # false
raise 'MESSAGE CONTENTS MISMATCH'
end
import { createHmac } from 'crypto'
// based on https://github.com/stripe/stripe-node/blob/v12.16.0/src/Webhooks.ts
// see LICENSE for more details https://github.com/stripe/stripe-node/blob/v12.16.0/LICENSE
const verifySignature = (
tidySignatureHeader: string,
signingKeyB64: string,
body: string,
registeredWebhookId: string,
httpMethod: string
) => {
const signingKey = Buffer.from(signingKeyB64, 'base64')
const details = parseHeader(tidySignatureHeader, 'v1')
const tolerance = 300
if (!details || details.timestamp === -1) {
throw new Error('Unable to extract timestamp and signatures from header')
}
if (!details.signatures.length) {
throw new Error('No signatures found with expected scheme')
}
const timestamp = details.timestamp
const signature = details.signatures[0]
const timestampedPayload = `${timestamp}.${body}`
const expectedSignature = createHmac('sha256', signingKey)
.update(timestampedPayload, 'utf8')
.digest('hex')
if (signature !== expectedSignature) {
throw new Error('Signature mismatch')
}
const timestampAge = Math.floor(Date.now() / 1000) - timestamp
if (tolerance > 0 && timestampAge > tolerance) {
throw new Error('Timestamp outside the tolerance zone')
}
const data = JSON.parse(body)
if (data.webhook_id !== registeredWebhookId) {
throw new Error(
`There has been a webhook ID mismatch, expected ${registeredWebhookId} got ${data.webhook_id}`
)
}
if (data.http_method !== httpMethod) {
throw new Error(
`There has been a HTTP method mismatch, expected ${httpMethod} got ${data.http_method}`
)
}
return data
}
const parseHeader = (header: string | null | undefined, scheme: string) => {
if (typeof header !== 'string') {
return null
}
return header.split(',').reduce(
(accum, item) => {
const kv = item.split('=')
if (kv[0] === 't') {
accum.timestamp = parseInt(kv[1], 10)
}
if (kv[0] === scheme) {
accum.signatures.push(kv[1])
}
return accum
},
{
timestamp: -1,
signatures: [] as string[],
}
)
}