use std::borrow::Cow;
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use hmac::{Hmac, KeyInit, Mac};
use http::HeaderMap;
use sha1::Sha1;
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
type HmacSha1 = Hmac<Sha1>;
pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedDelivery {
pub signed_payload: Vec<u8>,
pub candidates: Vec<Vec<u8>>,
pub timestamp: Option<i64>,
}
pub trait SignatureScheme: Send + Sync {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery>;
fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
let trimmed = secret.trim();
(!trimmed.is_empty()).then_some(Cow::Borrowed(trimmed.as_bytes()))
}
fn digest(&self) -> Digest {
Digest::Sha256
}
fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
hmac_bytes(self.digest(), key, signed_payload)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Digest {
#[default]
Sha256,
Sha1,
}
fn hmac_bytes(digest: Digest, key: &[u8], payload: &[u8]) -> Vec<u8> {
match digest {
Digest::Sha256 => {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(payload);
mac.finalize().into_bytes().to_vec()
}
Digest::Sha1 => {
let mut mac =
<HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(payload);
mac.finalize().into_bytes().to_vec()
}
}
}
#[derive(Clone)]
pub struct WebhookVerifier {
scheme: Arc<dyn SignatureScheme>,
tolerance_secs: i64,
}
impl WebhookVerifier {
#[must_use]
pub fn new(scheme: impl SignatureScheme + 'static) -> Self {
Self {
scheme: Arc::new(scheme),
tolerance_secs: DEFAULT_TOLERANCE_SECS,
}
}
#[must_use]
pub const fn tolerance_secs(mut self, secs: i64) -> Self {
self.tolerance_secs = secs;
self
}
#[must_use]
pub fn verify(&self, secret: &str, headers: &HeaderMap, body: &[u8], now_unix: i64) -> bool {
if secret.trim().is_empty() {
return false; }
let Some(delivery) = self.scheme.extract(headers, body) else {
return false;
};
if let Some(sent) = delivery.timestamp
&& now_unix
.saturating_sub(sent)
.checked_abs()
.is_none_or(|delta| delta > self.tolerance_secs)
{
return false; }
let Some(key) = self.scheme.secret_key(secret) else {
return false;
};
let expected = self
.scheme
.expected_signature(&key, &delivery.signed_payload);
let mut matched = false;
for candidate in &delivery.candidates {
matched |= bool::from(candidate.as_slice().ct_eq(expected.as_slice()));
}
matched
}
}
impl std::fmt::Debug for WebhookVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookVerifier")
.field("tolerance_secs", &self.tolerance_secs)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Svix;
const SVIX_HEADERS: [(&str, &str, &str); 2] = [
("svix-id", "svix-timestamp", "svix-signature"),
("webhook-id", "webhook-timestamp", "webhook-signature"),
];
impl SignatureScheme for Svix {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
let (id, timestamp, signatures) = SVIX_HEADERS.iter().find_map(|(id, at, sig)| {
Some((
header(headers, id)?,
header(headers, at)?,
header(headers, sig)?,
))
})?;
let sent = timestamp.trim().parse::<i64>().ok()?;
let mut candidates = Vec::new();
for entry in signatures.split_whitespace() {
let Some((version, value)) = entry.split_once(',') else {
continue;
};
if version == "v1"
&& let Ok(bytes) = STANDARD.decode(value)
{
candidates.push(bytes);
}
}
if candidates.is_empty() {
return None;
}
Some(SignedDelivery {
signed_payload: [id.as_bytes(), b".", timestamp.as_bytes(), b".", body].concat(),
candidates,
timestamp: Some(sent),
})
}
fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
svix_secret_key(secret).map(Cow::Owned)
}
}
#[must_use]
pub fn svix_secret_key(secret: &str) -> Option<Vec<u8>> {
let trimmed = secret.trim();
let encoded = trimmed.strip_prefix("whsec_").unwrap_or(trimmed);
STANDARD.decode(encoded).ok().filter(|key| !key.is_empty())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StripeStyle {
pub header: &'static str,
}
impl Default for StripeStyle {
fn default() -> Self {
Self {
header: "Stripe-Signature",
}
}
}
impl SignatureScheme for StripeStyle {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
let raw = header(headers, self.header)?;
let mut timestamp = None;
let mut candidates = Vec::new();
for part in raw.split(',') {
let Some((key, value)) = part.split_once('=') else {
continue;
};
match key.trim() {
"t" => timestamp = value.trim().parse::<i64>().ok(),
"v1" => {
if let Some(bytes) = hex_decode(value.trim()) {
candidates.push(bytes);
}
}
_ => {} }
}
let sent = timestamp?;
if candidates.is_empty() {
return None;
}
Some(SignedDelivery {
signed_payload: [sent.to_string().as_bytes(), b".", body].concat(),
candidates,
timestamp: Some(sent),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderScheme {
pub signature: &'static str,
pub encoding: SignatureEncoding,
pub prefix: Option<&'static str>,
pub timestamp: Option<&'static str>,
}
impl SignatureScheme for ProviderScheme {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
let raw = header(headers, self.signature)?;
let timestamp = match self.timestamp {
Some(name) => Some(header(headers, name)?.trim().parse::<i64>().ok()?),
None => None,
};
let mut candidates = Vec::new();
for entry in raw.split([',', ' ']) {
let Some(entry) = entry.strip_prefix(self.prefix.unwrap_or("")) else {
continue;
};
let decoded = match self.encoding {
SignatureEncoding::Hex => hex_decode(entry),
SignatureEncoding::Base64 => STANDARD.decode(entry).ok(),
};
if let Some(bytes) = decoded {
candidates.push(bytes);
}
}
if candidates.is_empty() {
return None;
}
Some(SignedDelivery {
signed_payload: match timestamp {
Some(sent) => [sent.to_string().as_bytes(), b".", body].concat(),
None => body.to_vec(),
},
candidates,
timestamp,
})
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Github;
const GITHUB_SCHEME: ProviderScheme = ProviderScheme {
signature: "X-Hub-Signature-256",
encoding: SignatureEncoding::Hex,
prefix: Some("sha256="),
timestamp: None,
};
impl SignatureScheme for Github {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
GITHUB_SCHEME.extract(headers, body)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureEncoding {
Hex,
Base64,
}
const VERCEL_SIGNATURE_HEADER: &str = "x-vercel-signature";
const SHA1_TAG_LEN: usize = 20;
#[derive(Debug, Clone, Copy, Default)]
pub struct Vercel;
impl SignatureScheme for Vercel {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
let raw = header(headers, VERCEL_SIGNATURE_HEADER)?;
let candidate = hex_decode(raw.trim())?;
if candidate.len() != SHA1_TAG_LEN {
return None;
}
Some(SignedDelivery {
signed_payload: body.to_vec(),
candidates: vec![candidate],
timestamp: None,
})
}
fn digest(&self) -> Digest {
Digest::Sha1
}
}
const GITLAB_TOKEN_HEADER: &str = "X-Gitlab-Token";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SharedTokenScheme {
pub header: &'static str,
}
impl SignatureScheme for SharedTokenScheme {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
let _ = body;
let token = header(headers, self.header)?;
if token.is_empty() {
return None; }
Some(SignedDelivery {
signed_payload: Vec::new(),
candidates: vec![token.as_bytes().to_vec()],
timestamp: None,
})
}
fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
let _ = signed_payload;
key.to_vec()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Gitlab;
const GITLAB_TOKEN: SharedTokenScheme = SharedTokenScheme {
header: GITLAB_TOKEN_HEADER,
};
impl SignatureScheme for Gitlab {
fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
GITLAB_TOKEN.extract(headers, body)
}
fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
GITLAB_TOKEN.expected_signature(key, signed_payload)
}
}
fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name)?.to_str().ok()
}
fn hex_decode(value: &str) -> Option<Vec<u8>> {
if !value.len().is_multiple_of(2) {
return None;
}
(0..value.len())
.step_by(2)
.map(|at| u8::from_str_radix(&value[at..at + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::SignatureEncoding::{Base64, Hex};
use super::*;
const SECRET: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const WRONG: &str = "whsec_AAAAAAAA"; const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
const NOW: i64 = 1_800_000_000;
const STRIPE_SECRET: &str = "stripe-endpoint-secret-dummy";
const PROVIDER_SECRET: &str = "provider-endpoint-secret-dummy";
const VERCEL_SECRET: &str = "vercel-webhook-secret-dummy";
const GITLAB_SECRET: &str = "gitlab-shared-token-dummy";
const VERCEL_BODY: &[u8] =
br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_1"}}}"#;
fn mac_sha1(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
let mut mac =
<HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
for part in parts {
mac.update(part);
}
mac.finalize().into_bytes().to_vec()
}
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
pairs
.iter()
.map(|(name, value)| {
let name = name
.parse::<http::header::HeaderName>()
.expect("test header name");
(name, value.parse().expect("test header value"))
})
.collect()
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
fn mac(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
for part in parts {
mac.update(part);
}
mac.finalize().into_bytes().to_vec()
}
fn svix_sign(secret: &str, id: &str, at: i64, body: &[u8]) -> String {
let key = svix_secret_key(secret).expect("the fixture secret decodes");
let ts = at.to_string();
let payload = [id.as_bytes(), b".", ts.as_bytes(), b".", body];
format!("v1,{}", STANDARD.encode(mac(&key, &payload)))
}
fn svix_headers(at: &str, sigs: &str) -> HeaderMap {
headers(&[
("svix-id", ID),
("svix-timestamp", at),
("svix-signature", sigs),
])
}
fn svix_verifies(secret: &str, at: &str, sigs: &str, body: &[u8], now: i64) -> bool {
WebhookVerifier::new(Svix).verify(secret, &svix_headers(at, sigs), body, now)
}
fn stripe_sign(secret: &str, at: i64, body: &[u8]) -> String {
let ts = at.to_string();
let payload = [ts.as_bytes(), b".", body];
let digest = hex(&mac(secret.as_bytes(), &payload));
format!("t={at},v1={digest}")
}
fn stripe_verifies(secret: &str, header: &str, body: &[u8], now: i64) -> bool {
let got = headers(&[("Stripe-Signature", header)]);
WebhookVerifier::new(StripeStyle::default()).verify(secret, &got, body, now)
}
fn provider(
enc: SignatureEncoding,
prefix: Option<&'static str>,
at: Option<&'static str>,
) -> WebhookVerifier {
WebhookVerifier::new(ProviderScheme {
signature: "X-Signature",
encoding: enc,
prefix,
timestamp: at,
})
}
fn provider_sign(at: Option<i64>, body: &[u8]) -> String {
let key = PROVIDER_SECRET.as_bytes();
let digest = match at {
Some(sent) => mac(key, &[sent.to_string().as_bytes(), b".", body]),
None => mac(key, &[body]),
};
hex(&digest)
}
#[test]
fn an_svix_delivery_verifies() {
let body = br#"{"type":"pos.order.created"}"#;
let header = svix_sign(SECRET, ID, NOW, body);
assert!(svix_verifies(SECRET, "1800000000", &header, body, NOW));
let aliased = headers(&[
("webhook-id", ID),
("webhook-timestamp", "1800000000"),
("webhook-signature", &header),
]);
assert!(WebhookVerifier::new(Svix).verify(SECRET, &aliased, body, NOW));
let bare = SECRET.strip_prefix("whsec_").expect("prefixed");
assert!(svix_verifies(bare, "1800000000", &header, body, NOW));
}
#[test]
fn a_tampered_delivery_or_wrong_secret_refuses_but_a_rotation_matches() {
let body = b"{}";
let header = svix_sign(SECRET, ID, NOW, body);
let old = svix_sign(WRONG, ID, NOW, body);
let tampered = br#"{"changed":true}"#;
assert!(!svix_verifies(SECRET, "1800000000", &header, tampered, NOW));
assert!(!svix_verifies(WRONG, "1800000000", &header, body, NOW));
let swapped = svix_sign(SECRET, "msg_another", NOW, body);
assert!(!svix_verifies(SECRET, "1800000000", &swapped, body, NOW));
let both = format!("{old} v0,AAAA {header}");
assert!(svix_verifies(SECRET, "1800000000", &both, body, NOW));
assert!(!svix_verifies(SECRET, "1800000000", "v0,AAAA", body, NOW));
}
#[test]
fn a_delivery_outside_the_tolerance_is_refused() {
let body = b"{}";
let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
let stale = svix_sign(SECRET, ID, sent, body);
assert!(svix_verifies(SECRET, &sent.to_string(), &stale, body, sent));
assert!(!svix_verifies(SECRET, &sent.to_string(), &stale, body, NOW));
let future = NOW + DEFAULT_TOLERANCE_SECS + 1;
let late = svix_sign(SECRET, ID, future, body);
assert!(!svix_verifies(
SECRET,
&future.to_string(),
&late,
body,
NOW
));
let wide = WebhookVerifier::new(Svix).tolerance_secs(DEFAULT_TOLERANCE_SECS * 2);
assert!(wide.verify(SECRET, &svix_headers(&sent.to_string(), &stale), body, NOW));
}
#[test]
fn unreadable_svix_input_refuses() {
let body = b"{}";
let header = svix_sign(SECRET, ID, NOW, body);
assert!(!WebhookVerifier::new(Svix).verify(SECRET, &headers(&[]), body, NOW));
assert!(!svix_verifies(SECRET, "recently", &header, body, NOW));
assert!(!svix_verifies(SECRET, "1800000000", "garbage", body, NOW));
assert!(!svix_verifies(
"whsec_!!!!",
"1800000000",
&header,
body,
NOW
));
assert!(!svix_verifies("", "1800000000", &header, body, NOW));
}
#[test]
fn a_stripe_delivery_verifies_and_malformed_ones_refuse() {
let body = b"{}";
let header = stripe_sign(STRIPE_SECRET, NOW, body);
assert!(stripe_verifies(STRIPE_SECRET, &header, body, NOW));
let renamed = WebhookVerifier::new(StripeStyle {
header: "X-Signature",
});
let under = headers(&[("X-Signature", &header)]);
assert!(renamed.verify(STRIPE_SECRET, &under, body, NOW));
assert!(!stripe_verifies(STRIPE_SECRET, &header, b"x", NOW));
assert!(!stripe_verifies("another-secret-dummy", &header, body, NOW));
let digest = hex(&mac(
STRIPE_SECRET.as_bytes(),
&[NOW.to_string().as_bytes(), b".", body],
));
assert!(!stripe_verifies(
STRIPE_SECRET,
&format!("v1={digest}"),
body,
NOW
));
assert!(!stripe_verifies(
STRIPE_SECRET,
&format!("t=recently,v1={digest}"),
body,
NOW
));
assert!(!stripe_verifies(STRIPE_SECRET, "t=1800000000", body, NOW));
let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
let stale = stripe_sign(STRIPE_SECRET, sent, body);
assert!(!stripe_verifies(STRIPE_SECRET, &stale, body, NOW));
}
#[test]
fn a_provider_scheme_verifies_raw_and_timestamped_deliveries() {
let body = br#"{"event":"order.created"}"#;
let raw = provider(Hex, None, None);
let got = headers(&[("X-Signature", &provider_sign(None, body))]);
assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW));
assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW + 10_000));
assert!(!raw.verify(PROVIDER_SECRET, &got, b"x", NOW));
assert!(!raw.verify("other", &got, body, NOW));
assert!(
!raw.verify("", &got, body, NOW),
"an empty secret is not a key"
);
let stamped = provider(Hex, Some("sha256="), Some("X-At"));
let header = format!("sha256={}", provider_sign(Some(NOW), body));
let with_at = headers(&[("X-Signature", &header), ("X-At", "1800000000")]);
assert!(stamped.verify(PROVIDER_SECRET, &with_at, body, NOW));
let no_at = headers(&[("X-Signature", &header)]);
assert!(!stamped.verify(PROVIDER_SECRET, &no_at, body, NOW));
let no_sig = headers(&[("X-At", "1800000000")]);
assert!(!stamped.verify(PROVIDER_SECRET, &no_sig, body, NOW));
let b64 = provider(Base64, None, None);
let encoded = STANDARD.encode(mac(PROVIDER_SECRET.as_bytes(), &[body]));
let b64hdr = headers(&[("X-Signature", &encoded)]);
assert!(b64.verify(PROVIDER_SECRET, &b64hdr, body, NOW));
let junk = headers(&[("X-Signature", "!!!!")]);
assert!(!b64.verify(PROVIDER_SECRET, &junk, body, NOW));
}
#[test]
fn a_github_delivery_verifies() {
const GITHUB_SECRET: &str = "It's a Secret to Everybody";
const GITHUB_BODY: &[u8] = b"Hello, World!";
const GITHUB_SIGNATURE: &str =
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
let verifier = WebhookVerifier::new(Github);
assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW));
assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW + 10_000));
}
#[test]
fn a_tampered_github_delivery_or_wrong_secret_refuses() {
const GITHUB_SECRET: &str = "It's a Secret to Everybody";
const GITHUB_BODY: &[u8] = b"Hello, World!";
const GITHUB_SIGNATURE: &str =
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
let verifier = WebhookVerifier::new(Github);
let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
assert!(!verifier.verify(GITHUB_SECRET, &got, b"Hello, World?", NOW));
assert!(!verifier.verify("another-secret-dummy", &got, GITHUB_BODY, NOW));
assert!(
!verifier.verify("", &got, GITHUB_BODY, NOW),
"an empty secret is not a key"
);
}
#[test]
fn unreadable_github_input_refuses() {
const GITHUB_SECRET: &str = "It's a Secret to Everybody";
const GITHUB_BODY: &[u8] = b"Hello, World!";
const GITHUB_SIGNATURE: &str =
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
let verifier = WebhookVerifier::new(Github);
assert!(!verifier.verify(GITHUB_SECRET, &headers(&[]), GITHUB_BODY, NOW));
assert!(!verifier.verify(
GITHUB_SECRET,
&headers(&[("X-Hub-Signature", "sha1=deadbeef")]),
GITHUB_BODY,
NOW
));
assert!(!verifier.verify(
GITHUB_SECRET,
&headers(&[("X-Hub-Signature-256", "deadbeef")]),
GITHUB_BODY,
NOW
));
let unprefixed = GITHUB_SIGNATURE.strip_prefix("sha256=").expect("prefixed");
assert!(!verifier.verify(
GITHUB_SECRET,
&headers(&[("X-Hub-Signature-256", unprefixed)]),
GITHUB_BODY,
NOW
));
}
fn vercel_verifies(secret: &str, signature: &str, body: &[u8], now: i64) -> bool {
let got = headers(&[("x-vercel-signature", signature)]);
WebhookVerifier::new(Vercel).verify(secret, &got, body, now)
}
#[test]
fn hmac_sha1_matches_the_rfc_2202_vector() {
let tag = hex(&mac_sha1(b"Jefe", &[b"what do ya want for nothing?"]));
assert_eq!(tag, "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79");
}
#[test]
fn a_vercel_delivery_verifies_and_tampered_ones_refuse() {
let signature = hex(&mac_sha1(VERCEL_SECRET.as_bytes(), &[VERCEL_BODY]));
assert_eq!(signature, "d6543f159dfdf17c47130f1019ba14ed71bd4ca5");
assert!(vercel_verifies(VERCEL_SECRET, &signature, VERCEL_BODY, NOW));
assert!(vercel_verifies(
VERCEL_SECRET,
&signature,
VERCEL_BODY,
NOW + 10_000
));
let tampered =
br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_2"}}}"#;
assert!(!vercel_verifies(VERCEL_SECRET, &signature, tampered, NOW));
assert!(!vercel_verifies(
"another-secret-dummy",
&signature,
VERCEL_BODY,
NOW
));
assert!(!vercel_verifies("", &signature, VERCEL_BODY, NOW));
}
#[test]
fn unreadable_vercel_input_refuses() {
assert!(!WebhookVerifier::new(Vercel).verify(
VERCEL_SECRET,
&headers(&[]),
VERCEL_BODY,
NOW
));
assert!(!vercel_verifies(
VERCEL_SECRET,
&"a".repeat(39),
VERCEL_BODY,
NOW
));
assert!(!vercel_verifies(
VERCEL_SECRET,
"not-hex!!",
VERCEL_BODY,
NOW
));
assert!(!vercel_verifies(VERCEL_SECRET, "zzzz", VERCEL_BODY, NOW));
assert!(!vercel_verifies(VERCEL_SECRET, "abcd", VERCEL_BODY, NOW));
assert!(!vercel_verifies(
VERCEL_SECRET,
&"a".repeat(41),
VERCEL_BODY,
NOW
));
}
fn gitlab_verifies(secret: &str, token: &str, body: &[u8], now: i64) -> bool {
let got = headers(&[("X-Gitlab-Token", token)]);
WebhookVerifier::new(Gitlab).verify(secret, &got, body, now)
}
#[test]
fn a_gitlab_shared_token_verifies_and_ignores_the_body() {
let body = br#"{"object_kind":"push"}"#;
assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, body, NOW));
assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, b"", NOW));
assert!(gitlab_verifies(
GITLAB_SECRET,
GITLAB_SECRET,
b"a wholly different body",
NOW + 10_000
));
let lower = headers(&[("x-gitlab-token", GITLAB_SECRET)]);
assert!(WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &lower, body, NOW));
}
#[test]
fn wrong_or_unreadable_gitlab_tokens_refuse() {
let body = b"{}";
assert!(!gitlab_verifies(
GITLAB_SECRET,
"other-token-dummy",
body,
NOW
));
assert!(!gitlab_verifies(
GITLAB_SECRET,
"gitlab-shared-token-dummy-plus",
body,
NOW
));
assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &headers(&[]), body, NOW));
assert!(!gitlab_verifies(GITLAB_SECRET, "", body, NOW));
assert!(!gitlab_verifies("", GITLAB_SECRET, body, NOW));
let mut raw = HeaderMap::new();
raw.insert(
"X-Gitlab-Token",
http::HeaderValue::from_bytes(&[0xff, 0xfe, 0x80]).expect("opaque header value"),
);
assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &raw, body, NOW));
let custom = WebhookVerifier::new(SharedTokenScheme {
header: "X-Custom-Token",
});
assert!(custom.verify(
GITLAB_SECRET,
&headers(&[("X-Custom-Token", GITLAB_SECRET)]),
body,
NOW
));
assert!(!custom.verify(
GITLAB_SECRET,
&headers(&[("X-Gitlab-Token", GITLAB_SECRET)]),
body,
NOW
));
}
}