Security

Webhooks

Check that a webhook came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender, with timestamp tolerance; and sign your own.

Version 1.0.0 · 2 functions · 3 crates

#webhooks#api#payments#signing

Functions

What use webhooks.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use webhook_verify, webhook_sign from webhooks (Quillo 0.6).

webhook_verify(provider: str, body: str, headers: map, secret: str, tolerance_seconds: int) → boolor an error

Checks a webhook: provider is "stripe", "github", "slack", "shopify" or "standard"; body the raw request body (request.body); headers the request's headers; secret the endpoint's signing secret. Stripe, Slack and Standard Webhooks carry a timestamp, refused when more than tolerance_seconds from now (300 is usual; 0 skips that check). Gives true, or an error value saying what is wrong.

webhook_sign(provider: str, body: str, secret: str, timestamp: int, id: str) → mapor an error

The headers to send with a webhook of your own, signed as provider signs them, for body at timestamp (Unix seconds; 0 for now). For tests of a receiver, and for apps that send webhooks; "standard" also takes id from a webhook-id-style message id (empty: one made from the time).

Get it

$ quillo install webhooks               # webhooks.qwer into this folder$ quillo install webhooks --example     # and its example, webhooks.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/webhooks.qwer$ curl -O https://quillolang.org/plugins/files/webhooks.qwe

Then, in your program, all of it or only what you call:

main.qwe
use webhooks.qweruse webhook_verify, webhook_sign from webhooks      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
hmac = "0.12"sha2 = "0.10"base64 = "0.22"

Example

webhooks.qwe
// Receiving Stripe and GitHub webhooks, refusing any that were not signed with the secret.////   quillo run webhooks.qwe      then open http://localhost:8080/selftest//// Point Stripe at POST /hooks/stripe and GitHub at POST /hooks/github.use "webhooks.qwer"let STRIPE_SECRET = env("STRIPE_WEBHOOK_SECRET") ?? "whsec_test_quillo"let GITHUB_SECRET = env("GITHUB_WEBHOOK_SECRET") ?? "quillo-github-secret"route POST /hooks/stripe {    let ok = webhook_verify("stripe", request.body, request.headers, STRIPE_SECRET, 300)    if is_err(ok) { reply 400 ok.error }    { received: request.json.type }}route POST /hooks/github {    let ok = webhook_verify("github", request.body, request.headers, GITHUB_SECRET, 0)    if is_err(ok) { reply 401 ok.error }    { received: request.headers["x-github-event"] ?? "ping" }}// What a sender's signature looks like, and the checks on it.route GET /selftest {    let body = json.stringify({ type: "invoice.paid", data: { id: "in_42" } })    let stripe = webhook_sign("stripe", body, STRIPE_SECRET, 0, "")?    let old = webhook_sign("stripe", body, STRIPE_SECRET, int(now() / 1000) - 3600, "")?    // GitHub's header is HMAC-SHA256 in hex: Quillo's own crypto.hmac makes the same one.    let github = { "x-hub-signature-256": `sha256=${crypto.hmac(GITHUB_SECRET, body)}` }    {        stripe: webhook_verify("stripe", body, stripe, STRIPE_SECRET, 300)?,        stripe_changed_body: webhook_verify("stripe", replace(body, "42", "43"), stripe, STRIPE_SECRET, 300).error,        stripe_replayed: webhook_verify("stripe", body, old, STRIPE_SECRET, 300).error,        github: webhook_verify("github", body, github, GITHUB_SECRET, 0)?,        shopify: webhook_verify("shopify", body, webhook_sign("shopify", body, "shpss_1", 0, "")?, "shpss_1", 0)?,    }}
$ quillo run webhooks.qwe$ curl 'localhost:8080/selftest'

Source

webhooks.qwer
//! Check that a webhook really came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender (Svix, Resend, Clerk), with the timestamp's tolerance; and sign your own.//! version: 1.0.0//!//! [dependencies]//! hmac = "0.12"//! sha2 = "0.10"//! base64 = "0.22"use base64::engine::general_purpose::STANDARD;use base64::Engine;use hmac::{Hmac, Mac};use sha2::Sha256;use std::collections::HashMap;type HmacSha256 = Hmac<Sha256>;fn now() -> i64 {    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}fn mac(key: &[u8], parts: &[&[u8]]) -> HmacSha256 {    let mut m = HmacSha256::new_from_slice(key).expect("HMAC takes any key length");    for p in parts {        m.update(p);    }    m}fn hex(bytes: &[u8]) -> String {    bytes.iter().map(|b| format!("{:02x}", b)).collect()}fn unhex(s: &str) -> Option<Vec<u8>> {    if s.len() % 2 != 0 {        return None;    }    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()}fn header<'a>(headers: &'a HashMap<String, String>, name: &str) -> Result<&'a str, String> {    headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).ok_or_else(|| format!("no `{}` header", name))}fn fresh(ts: &str, tolerance: i64) -> Result<i64, String> {    let t: i64 = ts.trim().parse().map_err(|_| format!("the timestamp `{}` is not a number", ts))?;    if tolerance > 0 && (now() - t).abs() > tolerance {        return Err(format!("the timestamp is {} s away from now, more than the {} s allowed: a replay, or a clock that is off", (now() - t).abs(), tolerance));    }    Ok(t)}fn standard_key(secret: &str) -> Result<Vec<u8>, String> {    STANDARD.decode(secret.trim().trim_start_matches("whsec_")).map_err(|_| "a Standard Webhooks secret is `whsec_` and base64".to_string())}const NO_MATCH: &str = "the signature does not match: a wrong secret, or the body was changed (check it with the raw body, not one parsed and written again)";/// Checks a webhook: `provider` is "stripe", "github", "slack", "shopify" or "standard"; `body` the raw request body/// (`request.body`); `headers` the request's headers; `secret` the endpoint's signing secret. Stripe, Slack and Standard Webhooks/// carry a timestamp, refused when more than `tolerance_seconds` from now (300 is usual; 0 skips that check)./// Gives true, or an error value saying what is wrong.pub fn webhook_verify(provider: &str, body: &str, headers: std::collections::HashMap<String, String>, secret: &str, tolerance_seconds: i64) -> Result<bool, String> {    let b = body.as_bytes();    let ok = match provider.to_lowercase().as_str() {        "stripe" => {            let h = header(&headers, "stripe-signature")?;            let mut t = None;            let mut sigs = vec![];            for part in h.split(',') {                match part.trim().split_once('=') {                    Some(("t", v)) => t = Some(v.to_string()),                    Some(("v1", v)) => sigs.push(v.to_string()),                    _ => {}                }            }            let t = t.ok_or("the Stripe-Signature header has no t=")?;            fresh(&t, tolerance_seconds)?;            sigs.iter().filter_map(|s| unhex(s)).any(|s| mac(secret.as_bytes(), &[t.as_bytes(), b".", b]).verify_slice(&s).is_ok())        }        "github" => {            let h = header(&headers, "x-hub-signature-256")?;            let s = h.trim().strip_prefix("sha256=").ok_or("X-Hub-Signature-256 starts with sha256=")?;            unhex(s).is_some_and(|s| mac(secret.as_bytes(), &[b]).verify_slice(&s).is_ok())        }        "slack" => {            let t = header(&headers, "x-slack-request-timestamp")?;            fresh(t, tolerance_seconds)?;            let s = header(&headers, "x-slack-signature")?;            let s = s.trim().strip_prefix("v0=").ok_or("X-Slack-Signature starts with v0=")?;            unhex(s).is_some_and(|s| mac(secret.as_bytes(), &[b"v0:", t.trim().as_bytes(), b":", b]).verify_slice(&s).is_ok())        }        "shopify" => {            let s = header(&headers, "x-shopify-hmac-sha256")?;            STANDARD.decode(s.trim()).ok().is_some_and(|s| mac(secret.as_bytes(), &[b]).verify_slice(&s).is_ok())        }        "standard" => {            let id = header(&headers, "webhook-id")?;            let t = header(&headers, "webhook-timestamp")?;            fresh(t, tolerance_seconds)?;            let key = standard_key(secret)?;            let sigs = header(&headers, "webhook-signature")?;            sigs.split_whitespace().filter_map(|s| s.strip_prefix("v1,")).filter_map(|s| STANDARD.decode(s).ok())                .any(|s| mac(&key, &[id.as_bytes(), b".", t.trim().as_bytes(), b".", b]).verify_slice(&s).is_ok())        }        other => return Err(format!("unknown provider `{}`: stripe, github, slack, shopify or standard", other)),    };    if ok { Ok(true) } else { Err(NO_MATCH.into()) }}/// The headers to send with a webhook of your own, signed as `provider` signs them, for `body` at `timestamp`/// (Unix seconds; 0 for now). For tests of a receiver, and for apps that send webhooks; "standard" also takes `id`/// from a `webhook-id`-style message id (empty: one made from the time).pub fn webhook_sign(provider: &str, body: &str, secret: &str, timestamp: i64, id: &str) -> Result<HashMap<String, String>, String> {    let t = if timestamp > 0 { timestamp } else { now() };    let ts = t.to_string();    let b = body.as_bytes();    let sign = |parts: &[&[u8]]| mac(secret.as_bytes(), parts).finalize().into_bytes();    Ok(match provider.to_lowercase().as_str() {        "stripe" => HashMap::from([("stripe-signature".into(), format!("t={},v1={}", ts, hex(&sign(&[ts.as_bytes(), b".", b]))))]),        "github" => HashMap::from([("x-hub-signature-256".into(), format!("sha256={}", hex(&sign(&[b]))))]),        "slack" => HashMap::from([            ("x-slack-request-timestamp".into(), ts.clone()),            ("x-slack-signature".into(), format!("v0={}", hex(&sign(&[b"v0:", ts.as_bytes(), b":", b])))),        ]),        "shopify" => HashMap::from([("x-shopify-hmac-sha256".into(), STANDARD.encode(sign(&[b])))]),        "standard" => {            let id = if id.is_empty() { format!("msg_{}", t) } else { id.to_string() };            let key = standard_key(secret)?;            let s = mac(&key, &[id.as_bytes(), b".", ts.as_bytes(), b".", b]).finalize().into_bytes();            HashMap::from([                ("webhook-id".into(), id),                ("webhook-timestamp".into(), ts),                ("webhook-signature".into(), format!("v1,{}", STANDARD.encode(s))),            ])        }        other => return Err(format!("unknown provider `{}`: stripe, github, slack, shopify or standard", other)),    })}
Thread
59Useful? Like it, or add to its thread below.

Thread 0 posts

On Webhooks: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.