mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
ab2a15b2a8
The Zoom URL-validation challenge handler in `handle_challenge_request`
would HMAC-sign any arbitrary `plainToken` and return the result. Since
Zoom webhook verification checks `HMAC-SHA256(secret, "v0:{ts}:{body}")`,
an attacker could craft a `plainToken` in that format to obtain a valid
signature for a forged body, bypassing authentication on a later request.
Unlike the Twitch handler, the Zoom handler verifies no signature on the
challenge request (Zoom's protocol does not include one). Reject any
`plainToken` containing `:` or longer than 128 chars: legitimate Zoom
validation tokens are short random hex strings that never contain colons,
while the exploit requires the colon-bearing `v0:{ts}:{body}` format.
Fixes WIN-2008
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2000 lines
68 KiB
Rust
2000 lines
68 KiB
Rust
use axum::response::{IntoResponse, Response};
|
|
use base64::{
|
|
prelude::{BASE64_STANDARD, BASE64_URL_SAFE},
|
|
Engine,
|
|
};
|
|
use hmac::{Hmac, Mac};
|
|
use http::{header, HeaderMap, HeaderValue, StatusCode};
|
|
use itertools::Itertools;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
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>;
|
|
|
|
mod github {
|
|
use super::*;
|
|
pub struct Github;
|
|
|
|
impl WebhookHandler for Github {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
_: &'header HeaderMap,
|
|
_: &SignatureConfigData,
|
|
_: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let github_secret_header = headers.try_get_webhook_header("X-Hub-Signature-256")?;
|
|
|
|
let authentication_data = SignatureAuthenticationData::new(
|
|
Cow::Borrowed(raw_payload),
|
|
github_secret_header,
|
|
Some("sha256="),
|
|
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
|
|
);
|
|
|
|
Ok(authentication_data)
|
|
}
|
|
}
|
|
}
|
|
|
|
mod slack {
|
|
use super::*;
|
|
pub struct Slack;
|
|
|
|
impl WebhookHandler for Slack {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
_: &'header HeaderMap,
|
|
_: &SignatureConfigData,
|
|
_: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let slack_secret_signature = headers.try_get_webhook_header("X-Slack-Signature")?;
|
|
let slack_timestamp_header =
|
|
headers.try_get_webhook_header("X-Slack-Request-Timestamp")?;
|
|
let signed_payload = format!("v0:{}:{}", slack_timestamp_header, raw_payload);
|
|
|
|
Ok(SignatureAuthenticationData::new(
|
|
Cow::Owned(signed_payload),
|
|
slack_secret_signature,
|
|
Some("v0="),
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
mod stripe {
|
|
use super::*;
|
|
|
|
pub struct Stripe;
|
|
|
|
impl WebhookHandler for Stripe {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
_: &'header HeaderMap,
|
|
_: &SignatureConfigData,
|
|
_: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let stripe_signature_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?;
|
|
|
|
let stripe_signature = parse_signature(stripe_signature_header, (",", "="));
|
|
|
|
let timestamp = *stripe_signature
|
|
.get("t")
|
|
.ok_or(AuthenticationError::InvalidTimestamp)?;
|
|
let v1 = *stripe_signature
|
|
.get("v1")
|
|
.ok_or(AuthenticationError::InvalidSignature)?;
|
|
|
|
let signed_payload = format!("{}.{}", timestamp, raw_payload);
|
|
|
|
Ok(SignatureAuthenticationData::new(
|
|
Cow::Owned(signed_payload),
|
|
v1,
|
|
None,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
mod tiktok {
|
|
use super::*;
|
|
|
|
pub struct TikTok;
|
|
|
|
impl WebhookHandler for TikTok {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
_: &'header HeaderMap,
|
|
_: &SignatureConfigData,
|
|
_: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let tiktok_secret_signature = headers.try_get_webhook_header("TikTok-Signature")?;
|
|
|
|
let stripe_signature = parse_signature(tiktok_secret_signature, (",", "="));
|
|
|
|
let timestamp = *stripe_signature
|
|
.get("t")
|
|
.ok_or(AuthenticationError::InvalidTimestamp)?;
|
|
let s = *stripe_signature
|
|
.get("s")
|
|
.ok_or(AuthenticationError::InvalidSignature)?;
|
|
|
|
let signed_payload = format!("{}.{}", timestamp, raw_payload);
|
|
|
|
Ok(SignatureAuthenticationData::new(
|
|
Cow::Owned(signed_payload),
|
|
s,
|
|
None,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
mod twitch {
|
|
use super::*;
|
|
use http::header;
|
|
use serde_json::value::RawValue;
|
|
#[derive(Debug, Deserialize)]
|
|
struct TwitchCrcBody {
|
|
challenge: String,
|
|
#[allow(unused)]
|
|
subscription: Box<RawValue>,
|
|
}
|
|
|
|
pub struct Twitch;
|
|
|
|
impl WebhookHandler for Twitch {
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let twitch_secret_signature =
|
|
headers.try_get_webhook_header("Twitch-Eventsub-Message-Signature")?;
|
|
let twitch_message_id_header =
|
|
headers.try_get_webhook_header("Twitch-Eventsub-Message-Id")?;
|
|
let twitch_timestamp_header =
|
|
headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?;
|
|
|
|
let message = format!(
|
|
"{}{}{}",
|
|
twitch_message_id_header, twitch_timestamp_header, raw_payload
|
|
);
|
|
|
|
Ok(SignatureAuthenticationData::new(
|
|
Cow::Owned(message),
|
|
twitch_secret_signature,
|
|
Some("sha256="),
|
|
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
|
|
))
|
|
}
|
|
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
signature_config_data: &SignatureConfigData,
|
|
raw_payload: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
let authentication_data = self.get_hmac_authentication_data(headers, raw_payload)?;
|
|
verify_hmac_signature(authentication_data, &signature_config_data.secret_key)?;
|
|
|
|
let twitch_eventsub_message_type =
|
|
headers.try_get_webhook_header("Twitch-Eventsub-Message-Type")?;
|
|
|
|
if twitch_eventsub_message_type != "webhook_callback_verification" {
|
|
return Ok(None);
|
|
}
|
|
let twitch_crc_body =
|
|
serde_json::from_str::<TwitchCrcBody>(raw_payload).map_err(|e| {
|
|
AuthenticationError::InvalidChallengeResponse(format!(
|
|
"Twitch :{}",
|
|
e.to_string()
|
|
))
|
|
})?;
|
|
|
|
let response = (
|
|
StatusCode::OK,
|
|
[(header::CONTENT_TYPE, "text/plain")],
|
|
twitch_crc_body.challenge.to_string(),
|
|
);
|
|
|
|
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(())
|
|
}
|
|
}
|
|
}
|
|
|
|
mod zoom {
|
|
use axum::Json;
|
|
|
|
use super::*;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ZoomPayload {
|
|
#[serde(rename = "plainToken")]
|
|
plain_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[allow(unused)]
|
|
struct ZoomChallengeResponse {
|
|
payload: ZoomPayload,
|
|
event_ts: u64,
|
|
event: String,
|
|
}
|
|
|
|
pub struct Zoom;
|
|
|
|
impl WebhookHandler for Zoom {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
_: &'header HeaderMap,
|
|
signature_config_data: &SignatureConfigData,
|
|
raw_payload: &str,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
let Ok(zoom_request_body) = serde_json::from_str::<ZoomChallengeResponse>(raw_payload)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if zoom_request_body.event != "endpoint.url_validation" {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Prevent this challenge endpoint from being used as a signing oracle.
|
|
// Legitimate Zoom validation tokens are short random hex strings that
|
|
// never contain colons. The exploit requires crafting a plainToken in the
|
|
// `v0:{timestamp}:{body}` webhook-signing format (always containing colons)
|
|
// to obtain a valid signature for an arbitrary body. Reject any token that
|
|
// does not look like a legitimate Zoom validation token.
|
|
if zoom_request_body.payload.plain_token.contains(':')
|
|
|| zoom_request_body.payload.plain_token.len() > 128
|
|
{
|
|
return Err(AuthenticationError::InvalidChallengeResponse(
|
|
"Zoom: invalid plainToken format".to_string(),
|
|
));
|
|
}
|
|
|
|
let hmac_signature = calculate_hmac_signature(
|
|
HmacAlgorithm::Sha256,
|
|
&signature_config_data.secret_key,
|
|
&zoom_request_body.payload.plain_token,
|
|
);
|
|
|
|
let encoded_hmac_signature = encode_hmac_signature(Encoding::Hex, &hmac_signature);
|
|
|
|
let response = (
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"plainToken": zoom_request_body.payload.plain_token,
|
|
"encryptedToken": encoded_hmac_signature
|
|
})),
|
|
);
|
|
|
|
Ok(Some(response.into_response()))
|
|
}
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
raw_payload: &'payload str,
|
|
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
|
|
{
|
|
let zoom_signature_header = headers.try_get_webhook_header("x-zm-signature")?;
|
|
let zoom_timestamp_header = headers.try_get_webhook_header("x-zm-request-timestamp")?;
|
|
|
|
let message = format!("v0:{}:{}", zoom_timestamp_header, raw_payload);
|
|
|
|
Ok(SignatureAuthenticationData::new(
|
|
Cow::Owned(message),
|
|
zoom_signature_header,
|
|
Some("v0="),
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
use constant_time_eq::constant_time_eq;
|
|
use github::Github;
|
|
use slack::Slack;
|
|
use stripe::Stripe;
|
|
use tiktok::TikTok;
|
|
use twitch::Twitch;
|
|
use zoom::Zoom;
|
|
|
|
#[derive(Debug)]
|
|
pub struct SignatureAuthenticationDetails {
|
|
pub algorithm_to_use: HmacAlgorithm,
|
|
pub header_key_encoding: Encoding,
|
|
}
|
|
|
|
impl SignatureAuthenticationDetails {
|
|
#[inline]
|
|
fn new(algorithm_to_use: HmacAlgorithm, header_key_encoding: Encoding) -> Self {
|
|
Self { algorithm_to_use, header_key_encoding }
|
|
}
|
|
}
|
|
|
|
fn parse_signature<'header>(
|
|
signature: &'header str,
|
|
splitters: (&str, &str),
|
|
) -> HashMap<&'header str, &'header str> {
|
|
let headers: HashMap<&str, &str> = signature
|
|
.split(splitters.0)
|
|
.map(|header| {
|
|
let mut key_and_value = header.split(splitters.1);
|
|
let key = key_and_value.next();
|
|
let value = key_and_value.next();
|
|
(key, value)
|
|
})
|
|
.filter_map(|(key, value)| match (key, value) {
|
|
(Some(key), Some(value)) => Some((key, value)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
headers
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SignatureAuthenticationData<'payload, 'header, 'prefix> {
|
|
pub signed_payload: Cow<'payload, str>,
|
|
pub header_key_value: &'header str,
|
|
pub signature_prefix: Option<&'prefix str>,
|
|
pub config: SignatureAuthenticationDetails,
|
|
}
|
|
|
|
impl<'payload, 'header, 'prefix> SignatureAuthenticationData<'payload, 'header, 'prefix> {
|
|
pub fn new(
|
|
signed_payload: Cow<'payload, str>,
|
|
header_key_value: &'header str,
|
|
signature_prefix: Option<&'prefix str>,
|
|
config: SignatureAuthenticationDetails,
|
|
) -> Self {
|
|
Self { signed_payload, header_key_value, signature_prefix, config }
|
|
}
|
|
}
|
|
|
|
pub trait WebhookHandler {
|
|
fn handle_challenge_request<'header>(
|
|
&self,
|
|
headers: &'header HeaderMap,
|
|
signature_config_data: &SignatureConfigData,
|
|
raw_payload: &str,
|
|
) -> Result<Option<Response>, AuthenticationError>;
|
|
|
|
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
|
|
&self,
|
|
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)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum HmacAlgorithm {
|
|
Sha1,
|
|
Sha256,
|
|
Sha512,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Encoding {
|
|
Base64,
|
|
Base64Uri,
|
|
Hex,
|
|
}
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct SignatureAuthenticationMethod {
|
|
algorithm: HmacAlgorithm,
|
|
encoding: Encoding,
|
|
signature_header_name: String,
|
|
signature_prefix: Option<String>,
|
|
}
|
|
|
|
pub struct SignatureConfigData<'config> {
|
|
secret_key: &'config str,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct SignatureAuthentication {
|
|
signature_provider: WebhookType,
|
|
secret_key: String,
|
|
authentication_config: Option<SignatureAuthenticationMethod>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct BasicAuthAuthentication {
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct ApiKeyAuthentication {
|
|
pub api_key_header: String,
|
|
pub api_key_secret: String,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)]
|
|
#[non_exhaustive]
|
|
pub enum WebhookType {
|
|
Github,
|
|
Slack,
|
|
Stripe,
|
|
TikTok,
|
|
Twitch,
|
|
Zoom,
|
|
Custom,
|
|
}
|
|
|
|
impl WebhookType {
|
|
pub fn get_webhook_handler(&self) -> Option<&'static dyn WebhookHandler> {
|
|
let handler: &'static dyn WebhookHandler = match *self {
|
|
WebhookType::Github => &Github,
|
|
WebhookType::Slack => &Slack,
|
|
WebhookType::Stripe => &Stripe,
|
|
WebhookType::TikTok => &TikTok,
|
|
WebhookType::Twitch => &Twitch,
|
|
WebhookType::Zoom => &Zoom,
|
|
WebhookType::Custom => return None,
|
|
};
|
|
Some(handler)
|
|
}
|
|
}
|
|
|
|
trait TryGetWebhookHeader {
|
|
fn try_get_webhook_header<'header>(
|
|
&'header self,
|
|
header_name: &str,
|
|
) -> Result<&'header str, AuthenticationError>;
|
|
}
|
|
|
|
impl TryGetWebhookHeader for HeaderMap<HeaderValue> {
|
|
fn try_get_webhook_header<'header>(
|
|
&'header self,
|
|
header_name: &str,
|
|
) -> Result<&'header str, AuthenticationError> {
|
|
let Some(signature_header) = self.get(header_name) else {
|
|
return Err(AuthenticationError::MissingHeader(header_name.to_string()));
|
|
};
|
|
let Some(signature_header) = signature_header.to_str().ok() else {
|
|
return Err(AuthenticationError::InvalidHeader(header_name.to_string()));
|
|
};
|
|
|
|
Ok(signature_header)
|
|
}
|
|
}
|
|
|
|
pub fn calculate_hmac_signature(algorithm: HmacAlgorithm, secret: &str, payload: &str) -> Vec<u8> {
|
|
match algorithm {
|
|
HmacAlgorithm::Sha1 => {
|
|
let mut mac =
|
|
HmacSha1::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
|
|
mac.update(payload.as_bytes());
|
|
mac.finalize().into_bytes().to_vec()
|
|
}
|
|
HmacAlgorithm::Sha256 => {
|
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
.expect("HMAC can take key of any size");
|
|
mac.update(payload.as_bytes());
|
|
mac.finalize().into_bytes().to_vec()
|
|
}
|
|
HmacAlgorithm::Sha512 => {
|
|
let mut mac = HmacSha512::new_from_slice(secret.as_bytes())
|
|
.expect("HMAC can take key of any size");
|
|
mac.update(payload.as_bytes());
|
|
mac.finalize().into_bytes().to_vec()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn encode_hmac_signature(encoding: Encoding, hmac_signature: &[u8]) -> String {
|
|
match encoding {
|
|
Encoding::Hex => hex::encode(hmac_signature),
|
|
Encoding::Base64 => BASE64_STANDARD.encode(hmac_signature),
|
|
Encoding::Base64Uri => BASE64_URL_SAFE.encode(hmac_signature),
|
|
}
|
|
}
|
|
|
|
pub fn verify_hmac_signature(
|
|
authentication_data: SignatureAuthenticationData,
|
|
webhook_signing_secret: &str,
|
|
) -> Result<(), AuthenticationError> {
|
|
let hmac_signature = calculate_hmac_signature(
|
|
authentication_data.config.algorithm_to_use,
|
|
&webhook_signing_secret,
|
|
&authentication_data.signed_payload,
|
|
);
|
|
|
|
let encoded_signature = encode_hmac_signature(
|
|
authentication_data.config.header_key_encoding,
|
|
&hmac_signature,
|
|
);
|
|
|
|
let final_expected_signature =
|
|
if let Some(signature_prefix) = authentication_data.signature_prefix {
|
|
format!("{}{}", signature_prefix, encoded_signature)
|
|
} else {
|
|
encoded_signature
|
|
};
|
|
|
|
if !constant_time_eq(
|
|
final_expected_signature.as_bytes(),
|
|
authentication_data.header_key_value.as_bytes(),
|
|
) {
|
|
return Err(AuthenticationError::InvalidSignature);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(untagged)]
|
|
pub enum AuthenticationMethod {
|
|
Signature(SignatureAuthentication),
|
|
BasicAuth(BasicAuthAuthentication),
|
|
ApiKey(ApiKeyAuthentication),
|
|
}
|
|
|
|
impl AuthenticationMethod {
|
|
pub fn authenticate_http_request(
|
|
&self,
|
|
headers: &HeaderMap,
|
|
raw_payload: Option<&String>,
|
|
) -> Result<Option<Response>, AuthenticationError> {
|
|
match self {
|
|
AuthenticationMethod::Signature(SignatureAuthentication {
|
|
secret_key,
|
|
authentication_config,
|
|
signature_provider,
|
|
}) => {
|
|
let raw_payload = raw_payload.ok_or(AuthenticationError::InvalidPayload)?;
|
|
let config_data = SignatureConfigData { secret_key: &secret_key };
|
|
let handler = signature_provider.get_webhook_handler();
|
|
let challenge_response = handler
|
|
.map(|handler| {
|
|
handler.handle_challenge_request(headers, &config_data, raw_payload)
|
|
})
|
|
.transpose()?
|
|
.flatten();
|
|
|
|
if let Some(challenge_response) = challenge_response {
|
|
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 => {
|
|
let authentication_config = authentication_config
|
|
.as_ref()
|
|
.ok_or(AuthenticationError::InvalidCustomConfig)?;
|
|
let signature_header_value = headers
|
|
.try_get_webhook_header(&authentication_config.signature_header_name)?;
|
|
SignatureAuthenticationData::new(
|
|
Cow::Borrowed(raw_payload),
|
|
signature_header_value,
|
|
authentication_config.signature_prefix.as_deref(),
|
|
SignatureAuthenticationDetails::new(
|
|
authentication_config.algorithm,
|
|
authentication_config.encoding,
|
|
),
|
|
)
|
|
}
|
|
};
|
|
|
|
verify_hmac_signature(authentication_data, &secret_key)?;
|
|
}
|
|
AuthenticationMethod::ApiKey(ApiKeyAuthentication {
|
|
api_key_header,
|
|
api_key_secret,
|
|
}) => {
|
|
let api_key_to_cmp = headers
|
|
.try_get_webhook_header(&api_key_header)
|
|
.map_err(|_| AuthenticationError::InvalidApiKey)?;
|
|
if !constant_time_eq(api_key_to_cmp.as_bytes(), api_key_secret.as_bytes()) {
|
|
return Err(AuthenticationError::InvalidApiKey);
|
|
}
|
|
}
|
|
AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username, password }) => {
|
|
let mut credentials_store = headers
|
|
.try_get_webhook_header("Authorization")
|
|
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?
|
|
.split(' ');
|
|
|
|
let _ = credentials_store
|
|
.next()
|
|
.filter(|r#type| *r#type == "Basic")
|
|
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
|
|
|
|
let credentials_as_base64 = credentials_store
|
|
.next()
|
|
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
|
|
|
|
let credentials_from_base64_as_bytes = BASE64_STANDARD
|
|
.decode(credentials_as_base64.as_bytes())
|
|
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
|
|
|
|
let credentials_separated_with_colon =
|
|
String::from_utf8(credentials_from_base64_as_bytes)
|
|
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
|
|
|
|
let credentials = credentials_separated_with_colon.split(':').collect_vec();
|
|
|
|
if credentials.len() != 2 {
|
|
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
|
|
}
|
|
|
|
if !constant_time_eq(credentials.get(0).unwrap().as_bytes(), username.as_bytes())
|
|
|| !constant_time_eq(
|
|
credentials.get(1).unwrap().as_bytes(),
|
|
password.as_bytes(),
|
|
)
|
|
{
|
|
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum AuthenticationError {
|
|
#[error("failed to parse timestamp")]
|
|
InvalidTimestamp,
|
|
|
|
#[error("invalid secret")]
|
|
InvalidSecret(#[from] base64::DecodeError),
|
|
|
|
#[error("invalid header `{0}`")]
|
|
InvalidHeader(String),
|
|
|
|
#[error("signature timestamp too old")]
|
|
TimestampTooOldError,
|
|
|
|
#[error("signature timestamp too far in future")]
|
|
FutureTimestampError,
|
|
|
|
#[error("missing header {0}")]
|
|
MissingHeader(String),
|
|
|
|
#[error("signature invalid")]
|
|
InvalidSignature,
|
|
|
|
#[error("payload invalid")]
|
|
InvalidPayload,
|
|
|
|
#[error("invalid custom config")]
|
|
InvalidCustomConfig,
|
|
|
|
#[error("invalid auth header: {0}")]
|
|
InvalidAuthHeader(String),
|
|
|
|
#[error("invalid api key")]
|
|
InvalidApiKey,
|
|
|
|
#[error("invalid challenge response: {0}")]
|
|
InvalidChallengeResponse(String),
|
|
|
|
#[error("")]
|
|
UnauthorizedBasicHttpAuth,
|
|
}
|
|
|
|
impl IntoResponse for AuthenticationError {
|
|
fn into_response(self) -> Response {
|
|
let (status, error_message) = match &self {
|
|
AuthenticationError::InvalidTimestamp
|
|
| AuthenticationError::InvalidPayload
|
|
| AuthenticationError::InvalidHeader(_)
|
|
| AuthenticationError::MissingHeader(_)
|
|
| AuthenticationError::TimestampTooOldError
|
|
| AuthenticationError::FutureTimestampError
|
|
| AuthenticationError::InvalidCustomConfig
|
|
| AuthenticationError::InvalidChallengeResponse(_) => {
|
|
(StatusCode::BAD_REQUEST, self.to_string())
|
|
}
|
|
|
|
AuthenticationError::InvalidSecret(_)
|
|
| AuthenticationError::InvalidSignature
|
|
| AuthenticationError::InvalidAuthHeader(_) => {
|
|
(StatusCode::UNAUTHORIZED, self.to_string())
|
|
}
|
|
AuthenticationError::UnauthorizedBasicHttpAuth => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
[(header::WWW_AUTHENTICATE, r#"Basic realm="Restricted Area""#)],
|
|
"Unauthorized",
|
|
)
|
|
.into_response()
|
|
}
|
|
AuthenticationError::InvalidApiKey => {
|
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
|
}
|
|
};
|
|
|
|
let body = json!({ "error": error_message });
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
|
|
|
(status, headers, body.to_string()).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// --- calculate_hmac_signature ---
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_deterministic() {
|
|
let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload");
|
|
let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload");
|
|
assert_eq!(sig1, sig2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_different_keys() {
|
|
let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "key1", "payload");
|
|
let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "key2", "payload");
|
|
assert_ne!(sig1, sig2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_different_payloads() {
|
|
let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload1");
|
|
let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload2");
|
|
assert_ne!(sig1, sig2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha1_length() {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha1, "secret", "payload");
|
|
assert_eq!(sig.len(), 20); // SHA1 = 160 bits = 20 bytes
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_length() {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload");
|
|
assert_eq!(sig.len(), 32); // SHA256 = 256 bits = 32 bytes
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha512_length() {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, "secret", "payload");
|
|
assert_eq!(sig.len(), 64); // SHA512 = 512 bits = 64 bytes
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_empty_payload() {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "");
|
|
assert_eq!(sig.len(), 32);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hmac_sha256_empty_key() {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "", "payload");
|
|
assert_eq!(sig.len(), 32);
|
|
}
|
|
|
|
// --- encode_hmac_signature ---
|
|
|
|
#[test]
|
|
fn test_encode_hex() {
|
|
let bytes = vec![0xde, 0xad, 0xbe, 0xef];
|
|
assert_eq!(encode_hmac_signature(Encoding::Hex, &bytes), "deadbeef");
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_base64() {
|
|
let bytes = vec![0xde, 0xad, 0xbe, 0xef];
|
|
let encoded = encode_hmac_signature(Encoding::Base64, &bytes);
|
|
assert_eq!(BASE64_STANDARD.decode(&encoded).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_base64uri() {
|
|
let bytes = vec![0xde, 0xad, 0xbe, 0xef];
|
|
let encoded = encode_hmac_signature(Encoding::Base64Uri, &bytes);
|
|
assert_eq!(BASE64_URL_SAFE.decode(&encoded).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_hex_empty() {
|
|
assert_eq!(encode_hmac_signature(Encoding::Hex, &[]), "");
|
|
}
|
|
|
|
// --- verify_hmac_signature round-trip ---
|
|
|
|
fn make_auth_data<'a>(
|
|
payload: &'a str,
|
|
header_value: &'a str,
|
|
prefix: Option<&'a str>,
|
|
algorithm: HmacAlgorithm,
|
|
encoding: Encoding,
|
|
) -> SignatureAuthenticationData<'a, 'a, 'a> {
|
|
SignatureAuthenticationData::new(
|
|
Cow::Borrowed(payload),
|
|
header_value,
|
|
prefix,
|
|
SignatureAuthenticationDetails::new(algorithm, encoding),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_hmac_sha256_hex_roundtrip() {
|
|
let secret = "my_webhook_secret";
|
|
let payload = r#"{"event":"push"}"#;
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&encoded,
|
|
None,
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Hex,
|
|
);
|
|
assert!(verify_hmac_signature(data, secret).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_hmac_sha256_base64_roundtrip() {
|
|
let secret = "my_secret";
|
|
let payload = "test body";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Base64, &sig);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&encoded,
|
|
None,
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Base64,
|
|
);
|
|
assert!(verify_hmac_signature(data, secret).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_hmac_sha512_hex_roundtrip() {
|
|
let secret = "long_secret_key";
|
|
let payload = "some data";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&encoded,
|
|
None,
|
|
HmacAlgorithm::Sha512,
|
|
Encoding::Hex,
|
|
);
|
|
assert!(verify_hmac_signature(data, secret).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_hmac_sha1_hex_roundtrip() {
|
|
let secret = "sha1_key";
|
|
let payload = "data";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha1, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
|
|
let data = make_auth_data(payload, &encoded, None, HmacAlgorithm::Sha1, Encoding::Hex);
|
|
assert!(verify_hmac_signature(data, secret).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_with_prefix() {
|
|
let secret = "key";
|
|
let payload = "body";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let header_value = format!("sha256={}", encoded);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&header_value,
|
|
Some("sha256="),
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Hex,
|
|
);
|
|
assert!(verify_hmac_signature(data, secret).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_wrong_signature() {
|
|
let data = make_auth_data(
|
|
"payload",
|
|
"wrong_signature_value",
|
|
None,
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Hex,
|
|
);
|
|
let result = verify_hmac_signature(data, "secret");
|
|
assert!(matches!(result, Err(AuthenticationError::InvalidSignature)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_wrong_key() {
|
|
let secret = "correct_key";
|
|
let payload = "body";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&encoded,
|
|
None,
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Hex,
|
|
);
|
|
let result = verify_hmac_signature(data, "wrong_key");
|
|
assert!(matches!(result, Err(AuthenticationError::InvalidSignature)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_wrong_prefix() {
|
|
let secret = "key";
|
|
let payload = "body";
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let header_value = format!("v0={}", encoded);
|
|
|
|
let data = make_auth_data(
|
|
payload,
|
|
&header_value,
|
|
Some("sha256="),
|
|
HmacAlgorithm::Sha256,
|
|
Encoding::Hex,
|
|
);
|
|
assert!(matches!(
|
|
verify_hmac_signature(data, secret),
|
|
Err(AuthenticationError::InvalidSignature)
|
|
));
|
|
}
|
|
|
|
// --- parse_signature ---
|
|
|
|
#[test]
|
|
fn test_parse_signature_stripe_format() {
|
|
let header = "t=1234567890,v1=abc123def456";
|
|
let parsed = parse_signature(header, (",", "="));
|
|
assert_eq!(parsed.get("t"), Some(&"1234567890"));
|
|
assert_eq!(parsed.get("v1"), Some(&"abc123def456"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_signature_tiktok_format() {
|
|
let header = "t=1234567890,s=signaturevalue";
|
|
let parsed = parse_signature(header, (",", "="));
|
|
assert_eq!(parsed.get("t"), Some(&"1234567890"));
|
|
assert_eq!(parsed.get("s"), Some(&"signaturevalue"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_signature_single_entry() {
|
|
let header = "key=value";
|
|
let parsed = parse_signature(header, (",", "="));
|
|
assert_eq!(parsed.get("key"), Some(&"value"));
|
|
assert_eq!(parsed.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_signature_empty_string() {
|
|
let parsed = parse_signature("", (",", "="));
|
|
assert!(parsed.is_empty() || parsed.len() == 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_signature_multiple_entries() {
|
|
let header = "a=1,b=2,c=3";
|
|
let parsed = parse_signature(header, (",", "="));
|
|
assert_eq!(parsed.len(), 3);
|
|
assert_eq!(parsed.get("a"), Some(&"1"));
|
|
assert_eq!(parsed.get("b"), Some(&"2"));
|
|
assert_eq!(parsed.get("c"), Some(&"3"));
|
|
}
|
|
|
|
// --- WebhookType serde ---
|
|
|
|
#[test]
|
|
fn test_webhook_type_serde_roundtrip() {
|
|
for wt in [
|
|
WebhookType::Github,
|
|
WebhookType::Slack,
|
|
WebhookType::Stripe,
|
|
WebhookType::TikTok,
|
|
WebhookType::Twitch,
|
|
WebhookType::Zoom,
|
|
WebhookType::Custom,
|
|
] {
|
|
let json = serde_json::to_value(wt).unwrap();
|
|
let deserialized: WebhookType = serde_json::from_value(json).unwrap();
|
|
assert_eq!(wt, deserialized);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_type_handler_known_providers() {
|
|
assert!(WebhookType::Github.get_webhook_handler().is_some());
|
|
assert!(WebhookType::Slack.get_webhook_handler().is_some());
|
|
assert!(WebhookType::Stripe.get_webhook_handler().is_some());
|
|
assert!(WebhookType::TikTok.get_webhook_handler().is_some());
|
|
assert!(WebhookType::Twitch.get_webhook_handler().is_some());
|
|
assert!(WebhookType::Zoom.get_webhook_handler().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_type_custom_has_no_handler() {
|
|
assert!(WebhookType::Custom.get_webhook_handler().is_none());
|
|
}
|
|
|
|
// --- HmacAlgorithm / Encoding serde ---
|
|
|
|
#[test]
|
|
fn test_hmac_algorithm_serde() {
|
|
assert_eq!(serde_json::to_value(HmacAlgorithm::Sha1).unwrap(), "sha1");
|
|
assert_eq!(
|
|
serde_json::to_value(HmacAlgorithm::Sha256).unwrap(),
|
|
"sha256"
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(HmacAlgorithm::Sha512).unwrap(),
|
|
"sha512"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encoding_serde() {
|
|
assert_eq!(serde_json::to_value(Encoding::Hex).unwrap(), "hex");
|
|
assert_eq!(serde_json::to_value(Encoding::Base64).unwrap(), "base64");
|
|
assert_eq!(
|
|
serde_json::to_value(Encoding::Base64Uri).unwrap(),
|
|
"base64uri"
|
|
);
|
|
}
|
|
|
|
// --- TryGetWebhookHeader ---
|
|
|
|
#[test]
|
|
fn test_try_get_header_present() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("X-Custom-Header", HeaderValue::from_static("value123"));
|
|
assert_eq!(
|
|
headers.try_get_webhook_header("X-Custom-Header").unwrap(),
|
|
"value123"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_get_header_missing() {
|
|
let headers = HeaderMap::new();
|
|
let result = headers.try_get_webhook_header("X-Missing");
|
|
assert!(matches!(result, Err(AuthenticationError::MissingHeader(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_get_header_case_insensitive() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-hub-signature-256", HeaderValue::from_static("sig"));
|
|
assert_eq!(
|
|
headers
|
|
.try_get_webhook_header("X-Hub-Signature-256")
|
|
.unwrap(),
|
|
"sig"
|
|
);
|
|
}
|
|
|
|
// --- GitHub webhook end-to-end ---
|
|
|
|
fn github_headers(secret: &str, payload: &str) -> HeaderMap {
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"X-Hub-Signature-256",
|
|
HeaderValue::from_str(&format!("sha256={}", encoded)).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn test_github_authenticate_valid() {
|
|
let secret = "github_webhook_secret";
|
|
let payload = r#"{"action":"opened","number":1}"#.to_string();
|
|
let headers = github_headers(secret, &payload);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Github,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_github_authenticate_wrong_secret() {
|
|
let payload = r#"{"action":"opened"}"#.to_string();
|
|
let headers = github_headers("correct_secret", &payload);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Github,
|
|
secret_key: "wrong_secret".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_github_authenticate_missing_header() {
|
|
let payload = r#"{"action":"opened"}"#.to_string();
|
|
let headers = HeaderMap::new();
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Github,
|
|
secret_key: "secret".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, Some(&payload)),
|
|
Err(AuthenticationError::MissingHeader(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_github_authenticate_no_payload() {
|
|
let headers = HeaderMap::new();
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Github,
|
|
secret_key: "secret".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::InvalidPayload)
|
|
));
|
|
}
|
|
|
|
// --- Slack webhook end-to-end ---
|
|
|
|
fn slack_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap {
|
|
let signed_payload = format!("v0:{}:{}", timestamp, payload);
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"X-Slack-Signature",
|
|
HeaderValue::from_str(&format!("v0={}", encoded)).unwrap(),
|
|
);
|
|
headers.insert(
|
|
"X-Slack-Request-Timestamp",
|
|
HeaderValue::from_str(timestamp).unwrap(),
|
|
);
|
|
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 = ¤t_timestamp();
|
|
let headers = slack_headers(secret, &payload, timestamp);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Slack,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_slack_authenticate_stale_timestamp_rejected() {
|
|
let secret = "slack_secret";
|
|
let payload = "data".to_string();
|
|
let headers = slack_headers(secret, &payload, "1000000000");
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Slack,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, Some(&payload)),
|
|
Err(AuthenticationError::TimestampTooOldError)
|
|
));
|
|
}
|
|
|
|
// --- Stripe webhook end-to-end ---
|
|
|
|
fn stripe_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap {
|
|
let signed_payload = format!("{}.{}", timestamp, payload);
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"STRIPE-SIGNATURE",
|
|
HeaderValue::from_str(&format!("t={},v1={}", timestamp, encoded)).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn test_stripe_authenticate_valid() {
|
|
let secret = "whsec_stripe_secret";
|
|
let payload = r#"{"id":"evt_123"}"#.to_string();
|
|
let timestamp = ¤t_timestamp();
|
|
let headers = stripe_headers(secret, &payload, timestamp);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Stripe,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_stripe_authenticate_wrong_secret() {
|
|
let payload = r#"{"id":"evt_123"}"#.to_string();
|
|
let headers = stripe_headers("correct", &payload, "12345");
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Stripe,
|
|
secret_key: "wrong".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_err());
|
|
}
|
|
|
|
// --- TikTok webhook end-to-end ---
|
|
|
|
fn tiktok_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap {
|
|
let signed_payload = format!("{}.{}", timestamp, payload);
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"TikTok-Signature",
|
|
HeaderValue::from_str(&format!("t={},s={}", timestamp, encoded)).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn test_tiktok_authenticate_valid() {
|
|
let secret = "tiktok_secret";
|
|
let payload = r#"{"event":"video.upload"}"#.to_string();
|
|
let timestamp = ¤t_timestamp();
|
|
let headers = tiktok_headers(secret, &payload, timestamp);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::TikTok,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
// --- Twitch webhook end-to-end ---
|
|
|
|
fn twitch_headers(
|
|
secret: &str,
|
|
payload: &str,
|
|
message_id: &str,
|
|
timestamp: &str,
|
|
message_type: &str,
|
|
) -> HeaderMap {
|
|
let message = format!("{}{}{}", message_id, timestamp, payload);
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &message);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"Twitch-Eventsub-Message-Signature",
|
|
HeaderValue::from_str(&format!("sha256={}", encoded)).unwrap(),
|
|
);
|
|
headers.insert(
|
|
"Twitch-Eventsub-Message-Id",
|
|
HeaderValue::from_str(message_id).unwrap(),
|
|
);
|
|
headers.insert(
|
|
"Twitch-Eventsub-Message-Timestamp",
|
|
HeaderValue::from_str(timestamp).unwrap(),
|
|
);
|
|
headers.insert(
|
|
"Twitch-Eventsub-Message-Type",
|
|
HeaderValue::from_str(message_type).unwrap(),
|
|
);
|
|
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 ts = current_rfc3339_timestamp();
|
|
let headers = twitch_headers(secret, &payload, "msg-123", &ts, "notification");
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Twitch,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
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",
|
|
&ts,
|
|
"webhook_callback_verification",
|
|
);
|
|
|
|
let handler = WebhookType::Twitch.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: secret };
|
|
let response = handler
|
|
.handle_challenge_request(&headers, &config_data, payload)
|
|
.unwrap();
|
|
assert!(response.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_twitch_non_challenge_returns_none() {
|
|
let secret = "twitch_secret";
|
|
let payload = r#"{"subscription":{},"event":{}}"#;
|
|
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 };
|
|
let response = handler
|
|
.handle_challenge_request(&headers, &config_data, payload)
|
|
.unwrap();
|
|
assert!(response.is_none());
|
|
}
|
|
|
|
// --- Zoom webhook end-to-end ---
|
|
|
|
fn zoom_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap {
|
|
let message = format!("v0:{}:{}", timestamp, payload);
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &message);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-zm-signature",
|
|
HeaderValue::from_str(&format!("v0={}", encoded)).unwrap(),
|
|
);
|
|
headers.insert(
|
|
"x-zm-request-timestamp",
|
|
HeaderValue::from_str(timestamp).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_authenticate_valid() {
|
|
let secret = "zoom_secret";
|
|
let payload = r#"{"event":"meeting.started"}"#.to_string();
|
|
let timestamp = ¤t_timestamp();
|
|
let headers = zoom_headers(secret, &payload, timestamp);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Zoom,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_challenge_response() {
|
|
let secret = "zoom_secret";
|
|
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"abc123"}}"#;
|
|
|
|
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: secret };
|
|
let response = handler
|
|
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
|
|
.unwrap();
|
|
assert!(response.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_non_challenge_returns_none() {
|
|
let payload =
|
|
r#"{"event":"meeting.started","event_ts":1234567890,"payload":{"plainToken":"abc"}}"#;
|
|
|
|
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: "secret" };
|
|
let response = handler
|
|
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
|
|
.unwrap();
|
|
assert!(response.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_challenge_normal_token_succeeds() {
|
|
// A legitimate Zoom validation token is a short random alphanumeric string.
|
|
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#;
|
|
|
|
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
|
let response = handler
|
|
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
|
|
.unwrap();
|
|
assert!(response.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_challenge_token_with_colons_rejected() {
|
|
// Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format
|
|
// would let an attacker obtain a valid webhook signature for an arbitrary body.
|
|
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#;
|
|
|
|
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
|
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload);
|
|
assert!(matches!(
|
|
result,
|
|
Err(AuthenticationError::InvalidChallengeResponse(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoom_challenge_token_too_long_rejected() {
|
|
// A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token.
|
|
let long_token = "a".repeat(129);
|
|
let payload = format!(
|
|
r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#,
|
|
long_token
|
|
);
|
|
|
|
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
|
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
|
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload);
|
|
assert!(matches!(
|
|
result,
|
|
Err(AuthenticationError::InvalidChallengeResponse(_))
|
|
));
|
|
}
|
|
|
|
// --- Custom webhook end-to-end ---
|
|
|
|
#[test]
|
|
fn test_custom_signature_authenticate_valid() {
|
|
let secret = "custom_key";
|
|
let payload = "custom body".to_string();
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &payload);
|
|
let encoded = encode_hmac_signature(Encoding::Hex, &sig);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("X-My-Signature", HeaderValue::from_str(&encoded).unwrap());
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Custom,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: Some(SignatureAuthenticationMethod {
|
|
algorithm: HmacAlgorithm::Sha256,
|
|
encoding: Encoding::Hex,
|
|
signature_header_name: "X-My-Signature".to_string(),
|
|
signature_prefix: None,
|
|
}),
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_signature_with_prefix() {
|
|
let secret = "key";
|
|
let payload = "body".to_string();
|
|
let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, secret, &payload);
|
|
let encoded = encode_hmac_signature(Encoding::Base64, &sig);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"X-Sig",
|
|
HeaderValue::from_str(&format!("hmac={}", encoded)).unwrap(),
|
|
);
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Custom,
|
|
secret_key: secret.to_string(),
|
|
authentication_config: Some(SignatureAuthenticationMethod {
|
|
algorithm: HmacAlgorithm::Sha512,
|
|
encoding: Encoding::Base64,
|
|
signature_header_name: "X-Sig".to_string(),
|
|
signature_prefix: Some("hmac=".to_string()),
|
|
}),
|
|
});
|
|
assert!(method
|
|
.authenticate_http_request(&headers, Some(&payload))
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_signature_missing_config() {
|
|
let payload = "body".to_string();
|
|
let headers = HeaderMap::new();
|
|
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Custom,
|
|
secret_key: "secret".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, Some(&payload)),
|
|
Err(AuthenticationError::InvalidCustomConfig)
|
|
));
|
|
}
|
|
|
|
// --- API key authentication ---
|
|
|
|
#[test]
|
|
fn test_api_key_authenticate_valid() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("X-API-Key", HeaderValue::from_static("my_secret_key_123"));
|
|
|
|
let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication {
|
|
api_key_header: "X-API-Key".to_string(),
|
|
api_key_secret: "my_secret_key_123".to_string(),
|
|
});
|
|
assert!(method.authenticate_http_request(&headers, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_key_authenticate_wrong_key() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("X-API-Key", HeaderValue::from_static("wrong_key"));
|
|
|
|
let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication {
|
|
api_key_header: "X-API-Key".to_string(),
|
|
api_key_secret: "correct_key".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::InvalidApiKey)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_key_authenticate_missing_header() {
|
|
let headers = HeaderMap::new();
|
|
|
|
let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication {
|
|
api_key_header: "X-API-Key".to_string(),
|
|
api_key_secret: "secret".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::InvalidApiKey)
|
|
));
|
|
}
|
|
|
|
// --- Basic auth ---
|
|
|
|
fn basic_auth_header(username: &str, password: &str) -> HeaderMap {
|
|
let credentials = BASE64_STANDARD.encode(format!("{}:{}", username, password));
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"Authorization",
|
|
HeaderValue::from_str(&format!("Basic {}", credentials)).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_valid() {
|
|
let headers = basic_auth_header("admin", "password123");
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "password123".to_string(),
|
|
});
|
|
assert!(method.authenticate_http_request(&headers, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_wrong_password() {
|
|
let headers = basic_auth_header("admin", "wrong");
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "correct".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::UnauthorizedBasicHttpAuth)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_wrong_username() {
|
|
let headers = basic_auth_header("wrong_user", "password");
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::UnauthorizedBasicHttpAuth)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_missing_header() {
|
|
let headers = HeaderMap::new();
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::UnauthorizedBasicHttpAuth)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_bearer_instead_of_basic() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"Authorization",
|
|
HeaderValue::from_static("Bearer sometoken"),
|
|
);
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::UnauthorizedBasicHttpAuth)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_auth_invalid_base64() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"Authorization",
|
|
HeaderValue::from_static("Basic !!!invalid!!!"),
|
|
);
|
|
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "admin".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
assert!(matches!(
|
|
method.authenticate_http_request(&headers, None),
|
|
Err(AuthenticationError::UnauthorizedBasicHttpAuth)
|
|
));
|
|
}
|
|
|
|
// --- AuthenticationMethod serde (untagged enum) ---
|
|
|
|
#[test]
|
|
fn test_authentication_method_signature_serde() {
|
|
let method = AuthenticationMethod::Signature(SignatureAuthentication {
|
|
signature_provider: WebhookType::Github,
|
|
secret_key: "secret".to_string(),
|
|
authentication_config: None,
|
|
});
|
|
let json = serde_json::to_value(&method).unwrap();
|
|
assert_eq!(json["signature_provider"], "Github");
|
|
assert_eq!(json["secret_key"], "secret");
|
|
|
|
let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap();
|
|
match deserialized {
|
|
AuthenticationMethod::Signature(sig) => {
|
|
assert_eq!(sig.signature_provider, WebhookType::Github);
|
|
assert_eq!(sig.secret_key, "secret");
|
|
}
|
|
_ => panic!("expected Signature variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_authentication_method_api_key_serde() {
|
|
let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication {
|
|
api_key_header: "X-Key".to_string(),
|
|
api_key_secret: "val".to_string(),
|
|
});
|
|
let json = serde_json::to_value(&method).unwrap();
|
|
let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap();
|
|
match deserialized {
|
|
AuthenticationMethod::ApiKey(ak) => {
|
|
assert_eq!(ak.api_key_header, "X-Key");
|
|
assert_eq!(ak.api_key_secret, "val");
|
|
}
|
|
_ => panic!("expected ApiKey variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_authentication_method_basic_auth_serde() {
|
|
let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication {
|
|
username: "user".to_string(),
|
|
password: "pass".to_string(),
|
|
});
|
|
let json = serde_json::to_value(&method).unwrap();
|
|
let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap();
|
|
match deserialized {
|
|
AuthenticationMethod::BasicAuth(ba) => {
|
|
assert_eq!(ba.username, "user");
|
|
assert_eq!(ba.password, "pass");
|
|
}
|
|
_ => panic!("expected BasicAuth variant"),
|
|
}
|
|
}
|
|
|
|
// --- AuthenticationError into_response ---
|
|
|
|
#[test]
|
|
fn test_error_invalid_signature_is_401() {
|
|
let response = AuthenticationError::InvalidSignature.into_response();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_missing_header_is_400() {
|
|
let response = AuthenticationError::MissingHeader("X-Sig".to_string()).into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_invalid_payload_is_400() {
|
|
let response = AuthenticationError::InvalidPayload.into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_invalid_api_key_is_401() {
|
|
let response = AuthenticationError::InvalidApiKey.into_response();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_basic_auth_has_www_authenticate() {
|
|
let response = AuthenticationError::UnauthorizedBasicHttpAuth.into_response();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
assert!(response.headers().contains_key("www-authenticate"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_invalid_custom_config_is_400() {
|
|
let response = AuthenticationError::InvalidCustomConfig.into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_invalid_timestamp_is_400() {
|
|
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());
|
|
}
|
|
}
|