fix: add timestamp validation to webhook signature verification (#8596)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-29 12:19:59 +00:00
committed by GitHub
parent b4d1f2aac7
commit 74fba2abf3
3 changed files with 171 additions and 25 deletions

1
backend/Cargo.lock generated
View File

@@ -17342,6 +17342,7 @@ dependencies = [
"async-trait",
"axum 0.8.4",
"base64 0.22.1",
"chrono",
"constant_time_eq 0.3.1",
"futures",
"hex",

View File

@@ -45,3 +45,4 @@ itertools.workspace = true
thiserror.workspace = true
anyhow.workspace = true
hex.workspace = true
chrono.workspace = true

View File

@@ -12,6 +12,23 @@ use sha1::Sha1;
use sha2::{Sha256, Sha512};
use std::{borrow::Cow, collections::HashMap};
const MAX_TIMESTAMP_AGE_SECS: i64 = 300; // 5 minutes
fn validate_unix_timestamp(timestamp_str: &str) -> Result<(), AuthenticationError> {
let ts: i64 = timestamp_str
.parse()
.map_err(|_| AuthenticationError::InvalidTimestamp)?;
let now = chrono::Utc::now().timestamp();
let diff = now - ts;
if diff > MAX_TIMESTAMP_AGE_SECS {
return Err(AuthenticationError::TimestampTooOldError);
}
if diff < -MAX_TIMESTAMP_AGE_SECS {
return Err(AuthenticationError::FutureTimestampError);
}
Ok(())
}
pub type HmacSha256 = Hmac<Sha256>;
pub type HmacSha512 = Hmac<Sha512>;
pub type HmacSha1 = Hmac<Sha1>;
@@ -82,6 +99,11 @@ mod slack {
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> {
let ts = headers.try_get_webhook_header("X-Slack-Request-Timestamp")?;
validate_unix_timestamp(ts)
}
}
}
@@ -126,6 +148,13 @@ mod stripe {
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> {
let sig_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?;
let sig = parse_signature(sig_header, (",", "="));
let ts = *sig.get("t").ok_or(AuthenticationError::InvalidTimestamp)?;
validate_unix_timestamp(ts)
}
}
}
@@ -170,6 +199,13 @@ mod tiktok {
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> {
let sig_header = headers.try_get_webhook_header("TikTok-Signature")?;
let sig = parse_signature(sig_header, (",", "="));
let ts = *sig.get("t").ok_or(AuthenticationError::InvalidTimestamp)?;
validate_unix_timestamp(ts)
}
}
}
@@ -244,6 +280,23 @@ mod twitch {
Ok(Some(response.into_response()))
}
fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> {
let ts_str = headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?;
let ts: chrono::DateTime<chrono::Utc> = chrono::DateTime::parse_from_rfc3339(ts_str)
.map_err(|_| AuthenticationError::InvalidTimestamp)?
.into();
let now = chrono::Utc::now();
let diff = (now - ts).num_seconds();
// Twitch recommends 10 minutes tolerance
if diff > 600 {
return Err(AuthenticationError::TimestampTooOldError);
}
if diff < -600 {
return Err(AuthenticationError::FutureTimestampError);
}
Ok(())
}
}
}
@@ -321,6 +374,11 @@ mod zoom {
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> {
let ts = headers.try_get_webhook_header("x-zm-request-timestamp")?;
validate_unix_timestamp(ts)
}
}
}
@@ -397,6 +455,10 @@ pub trait WebhookHandler {
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>;
fn validate_timestamp(&self, _headers: &HeaderMap) -> Result<(), AuthenticationError> {
Ok(())
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
@@ -592,6 +654,10 @@ impl AuthenticationMethod {
return Ok(Some(challenge_response));
}
if let Some(handler) = handler {
handler.validate_timestamp(headers)?;
}
let authentication_data = match handler {
Some(handler) => handler.get_hmac_authentication_data(headers, raw_payload)?,
None => {
@@ -670,7 +736,6 @@ impl AuthenticationMethod {
}
#[derive(thiserror::Error, Debug)]
#[allow(unused)]
pub enum AuthenticationError {
#[error("failed to parse timestamp")]
InvalidTimestamp,
@@ -1210,11 +1275,15 @@ mod tests {
headers
}
fn current_timestamp() -> String {
chrono::Utc::now().timestamp().to_string()
}
#[test]
fn test_slack_authenticate_valid() {
let secret = "slack_signing_secret";
let payload = "token=xxx&command=%2Ftest".to_string();
let timestamp = "1531420618";
let timestamp = &current_timestamp();
let headers = slack_headers(secret, &payload, timestamp);
let method = AuthenticationMethod::Signature(SignatureAuthentication {
@@ -1228,7 +1297,7 @@ mod tests {
}
#[test]
fn test_slack_authenticate_wrong_timestamp() {
fn test_slack_authenticate_stale_timestamp_rejected() {
let secret = "slack_secret";
let payload = "data".to_string();
let headers = slack_headers(secret, &payload, "1000000000");
@@ -1238,10 +1307,10 @@ mod tests {
secret_key: secret.to_string(),
authentication_config: None,
});
// Constructed with timestamp "1000000000" but that's valid - it just needs to match
assert!(method
.authenticate_http_request(&headers, Some(&payload))
.is_ok());
assert!(matches!(
method.authenticate_http_request(&headers, Some(&payload)),
Err(AuthenticationError::TimestampTooOldError)
));
}
// --- Stripe webhook end-to-end ---
@@ -1262,7 +1331,7 @@ mod tests {
fn test_stripe_authenticate_valid() {
let secret = "whsec_stripe_secret";
let payload = r#"{"id":"evt_123"}"#.to_string();
let timestamp = "1614556800";
let timestamp = &current_timestamp();
let headers = stripe_headers(secret, &payload, timestamp);
let method = AuthenticationMethod::Signature(SignatureAuthentication {
@@ -1308,7 +1377,7 @@ mod tests {
fn test_tiktok_authenticate_valid() {
let secret = "tiktok_secret";
let payload = r#"{"event":"video.upload"}"#.to_string();
let timestamp = "1700000000";
let timestamp = &current_timestamp();
let headers = tiktok_headers(secret, &payload, timestamp);
let method = AuthenticationMethod::Signature(SignatureAuthentication {
@@ -1353,17 +1422,16 @@ mod tests {
headers
}
fn current_rfc3339_timestamp() -> String {
chrono::Utc::now().to_rfc3339()
}
#[test]
fn test_twitch_authenticate_valid_notification() {
let secret = "twitch_secret";
let payload = r#"{"subscription":{},"event":{"user_id":"123"}}"#.to_string();
let headers = twitch_headers(
secret,
&payload,
"msg-123",
"2024-01-01T00:00:00Z",
"notification",
);
let ts = current_rfc3339_timestamp();
let headers = twitch_headers(secret, &payload, "msg-123", &ts, "notification");
let method = AuthenticationMethod::Signature(SignatureAuthentication {
signature_provider: WebhookType::Twitch,
@@ -1379,11 +1447,12 @@ mod tests {
fn test_twitch_challenge_response() {
let secret = "twitch_secret";
let payload = r#"{"challenge":"test_challenge_string","subscription":{"id":"sub-123"}}"#;
let ts = current_rfc3339_timestamp();
let headers = twitch_headers(
secret,
payload,
"msg-456",
"2024-01-01T00:00:00Z",
&ts,
"webhook_callback_verification",
);
@@ -1399,13 +1468,8 @@ mod tests {
fn test_twitch_non_challenge_returns_none() {
let secret = "twitch_secret";
let payload = r#"{"subscription":{},"event":{}}"#;
let headers = twitch_headers(
secret,
payload,
"msg-789",
"2024-01-01T00:00:00Z",
"notification",
);
let ts = current_rfc3339_timestamp();
let headers = twitch_headers(secret, payload, "msg-789", &ts, "notification");
let handler = WebhookType::Twitch.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: secret };
@@ -1437,7 +1501,7 @@ mod tests {
fn test_zoom_authenticate_valid() {
let secret = "zoom_secret";
let payload = r#"{"event":"meeting.started"}"#.to_string();
let timestamp = "1700000000";
let timestamp = &current_timestamp();
let headers = zoom_headers(secret, &payload, timestamp);
let method = AuthenticationMethod::Signature(SignatureAuthentication {
@@ -1792,4 +1856,84 @@ mod tests {
let response = AuthenticationError::InvalidTimestamp.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
// --- validate_unix_timestamp ---
#[test]
fn test_validate_unix_timestamp_current() {
let now = chrono::Utc::now().timestamp().to_string();
assert!(validate_unix_timestamp(&now).is_ok());
}
#[test]
fn test_validate_unix_timestamp_recent() {
let ts = (chrono::Utc::now().timestamp() - 60).to_string();
assert!(validate_unix_timestamp(&ts).is_ok());
}
#[test]
fn test_validate_unix_timestamp_too_old() {
let ts = (chrono::Utc::now().timestamp() - 600).to_string();
assert!(matches!(
validate_unix_timestamp(&ts),
Err(AuthenticationError::TimestampTooOldError)
));
}
#[test]
fn test_validate_unix_timestamp_future() {
let ts = (chrono::Utc::now().timestamp() + 600).to_string();
assert!(matches!(
validate_unix_timestamp(&ts),
Err(AuthenticationError::FutureTimestampError)
));
}
#[test]
fn test_validate_unix_timestamp_invalid() {
assert!(matches!(
validate_unix_timestamp("not-a-number"),
Err(AuthenticationError::InvalidTimestamp)
));
}
// --- Slack timestamp validation ---
#[test]
fn test_slack_validate_timestamp_current() {
let handler = slack::Slack;
let mut headers = HeaderMap::new();
let now = chrono::Utc::now().timestamp().to_string();
headers.insert("X-Slack-Request-Timestamp", now.parse().unwrap());
assert!(handler.validate_timestamp(&headers).is_ok());
}
#[test]
fn test_slack_validate_timestamp_stale() {
let handler = slack::Slack;
let mut headers = HeaderMap::new();
let old = (chrono::Utc::now().timestamp() - 600).to_string();
headers.insert("X-Slack-Request-Timestamp", old.parse().unwrap());
assert!(handler.validate_timestamp(&headers).is_err());
}
// --- Twitch timestamp validation (ISO 8601) ---
#[test]
fn test_twitch_validate_timestamp_current() {
let handler = twitch::Twitch;
let mut headers = HeaderMap::new();
let now = chrono::Utc::now().to_rfc3339();
headers.insert("Twitch-Eventsub-Message-Timestamp", now.parse().unwrap());
assert!(handler.validate_timestamp(&headers).is_ok());
}
#[test]
fn test_twitch_validate_timestamp_stale() {
let handler = twitch::Twitch;
let mut headers = HeaderMap::new();
let old = (chrono::Utc::now() - chrono::TimeDelta::seconds(1200)).to_rfc3339();
headers.insert("Twitch-Eventsub-Message-Timestamp", old.parse().unwrap());
assert!(handler.validate_timestamp(&headers).is_err());
}
}