Skip to main content

codoseo_notify/
webhook.rs

1//! The generic webhook: stable JSON, signed with the channel's secret.
2//!
3//! Receivers verify `X-CodoSEO-Signature: sha256=<hex>` — an HMAC-SHA256 with the channel secret
4//! over `{timestamp}.{body}`, where `timestamp` is the `X-CodoSEO-Timestamp` header (unix
5//! seconds) and `body` the raw request body. Reject timestamps that are too old to stop replays.
6
7use hmac::{Hmac, KeyInit, Mac};
8use serde_json::{Value, json};
9use sha2::Sha256;
10
11use crate::message::AlertMessage;
12
13pub const SIGNATURE_HEADER: &str = "X-CodoSEO-Signature";
14pub const TIMESTAMP_HEADER: &str = "X-CodoSEO-Timestamp";
15
16/// `{event, site, dashboard_url, crawl_id, changes: [...], more}`. `event` is `changes`,
17/// `unreachable` or `test`; `crawl_id` is null for a test.
18pub fn payload(msg: &AlertMessage) -> Value {
19    let changes: Vec<Value> = msg
20        .items
21        .iter()
22        .map(|i| {
23            json!({
24                "severity": i.severity,
25                "kind": i.kind,
26                "label": i.kind_label,
27                "url": i.url,
28                "before": i.before,
29                "after": i.after,
30            })
31        })
32        .collect();
33    json!({
34        "event": msg.kind.as_str(),
35        "site": msg.site_domain,
36        "dashboard_url": msg.site_url,
37        "crawl_id": msg.crawl_id,
38        "changes": changes,
39        "more": msg.more,
40    })
41}
42
43fn mac(secret: &str, timestamp: u64, body: &[u8]) -> Hmac<Sha256> {
44    let mut mac =
45        Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any length");
46    mac.update(timestamp.to_string().as_bytes());
47    mac.update(b".");
48    mac.update(body);
49    mac
50}
51
52/// `sha256=<lowercase hex>` over `{timestamp}.{body}`.
53pub fn sign(secret: &str, timestamp: u64, body: &[u8]) -> String {
54    format!(
55        "sha256={}",
56        hex::encode(mac(secret, timestamp, body).finalize().into_bytes())
57    )
58}
59
60/// Checks a signature header value in constant time.
61pub fn verify(secret: &str, timestamp: u64, body: &[u8], signature: &str) -> bool {
62    let Some(hex_sig) = signature.strip_prefix("sha256=") else {
63        return false;
64    };
65    let Ok(bytes) = hex::decode(hex_sig) else {
66        return false;
67    };
68    mac(secret, timestamp, body).verify_slice(&bytes).is_ok()
69}