diff --git a/README.md b/README.md deleted file mode 100644 index c1b4995..0000000 --- a/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# mymx - -Webhook receiver for [MyMX](https://mymx.email) email events. Verifies signatures and stores emails in PostgreSQL. - -## Usage - -```bash -createdb mymx -export DATABASE_URL="postgres:///mymx" -export MYMX_WEBHOOK_SECRET="your-webhook-secret" -sqlx migrate run --source mymx-server/migrations -cargo run -p mymx-server -``` - -Point your MyMX endpoint to `https://your-domain/webhook`. diff --git a/mymx-sdk/src/lib.rs b/mymx-sdk/src/lib.rs index 68a6f3c..d20763a 100644 --- a/mymx-sdk/src/lib.rs +++ b/mymx-sdk/src/lib.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use hmac::{Hmac, Mac}; use serde::Deserialize; use sha2::Sha256; @@ -12,6 +13,8 @@ pub enum Error { InvalidSignatureHeader, #[error("signature verification failed")] SignatureVerificationFailed, + #[error("timestamp too old (replay protection)")] + TimestampTooOld, #[error("failed to parse webhook body: {0}")] ParseError(#[from] serde_json::Error), #[error("HMAC error: {0}")] @@ -165,9 +168,16 @@ fn parse_signature_header(header: &str) -> Result<(i64, String), Error> { /// 2. Construct the signed payload: `"{timestamp}.{raw_body}"` /// 3. Compute HMAC-SHA256 with the webhook secret /// 4. Constant-time compare +/// 5. Reject timestamps older than 5 minutes pub fn verify_signature(raw_body: &str, signature_header: &str, secret: &str) -> Result<(), Error> { let (timestamp, expected_sig) = parse_signature_header(signature_header)?; + // Replay protection: reject timestamps older than 5 minutes + let now = Utc::now().timestamp(); + if now - timestamp > 300 { + return Err(Error::TimestampTooOld); + } + // Construct signed payload let payload = format!("{}.{}", timestamp, raw_body); @@ -203,7 +213,6 @@ pub fn handle_webhook( #[cfg(test)] mod tests { use super::*; - use chrono::Utc; #[test] fn test_parse_signature_header() { @@ -245,4 +254,21 @@ mod tests { assert_eq!(event.email.id, "e90fef93-9549-4e17-b86f-295c13089645"); } + #[test] + fn test_verify_signature_replay() { + let secret = "test_secret"; + let body = r#"{"test": true}"#; + let old_ts = Utc::now().timestamp() - 600; // 10 minutes ago + + let payload = format!("{}.{}", old_ts, body); + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(payload.as_bytes()); + let sig = hex::encode(mac.finalize().into_bytes()); + + let header = format!("t={},v1={}", old_ts, sig); + assert!(matches!( + verify_signature(body, &header, secret), + Err(Error::TimestampTooOld) + )); + } } diff --git a/mymx-server/src/main.rs b/mymx-server/src/main.rs index 9a64150..f3df830 100644 --- a/mymx-server/src/main.rs +++ b/mymx-server/src/main.rs @@ -62,6 +62,15 @@ fn map_webhook_error(e: mymx_sdk::Error) -> (StatusCode, Json) { }), ) } + mymx_sdk::Error::TimestampTooOld => { + tracing::warn!("Webhook rejected: timestamp too old (replay protection)"); + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "timestamp too old".into(), + }), + ) + } mymx_sdk::Error::HmacError(msg) => { tracing::warn!("Webhook rejected: HMAC error: {}", msg); ( @@ -209,7 +218,7 @@ async fn health_handler(State(state): State) -> impl IntoResponse { async fn index_handler(State(state): State) -> Result, StatusCode> { let emails: Vec = sqlx::query_as::<_, EmailRow>( - "SELECT body, subject, from_address, received_at FROM emails ORDER BY received_at DESC", + "SELECT body, subject, from_address, received_at FROM emails", ) .fetch_all(&state.db) .await @@ -218,7 +227,7 @@ async fn index_handler(State(state): State) -> Result, St StatusCode::INTERNAL_SERVER_ERROR })?; - let rows: Vec<(String, String, String, DateTime)> = emails + let mut rows: Vec<(String, String, String, DateTime)> = emails .into_iter() .map(|email| { let mut hasher = Sha256::new(); @@ -230,6 +239,9 @@ async fn index_handler(State(state): State) -> Result, St }) .collect(); + // Sort alphabetically by hash + rows.sort_by(|a, b| a.0.cmp(&b.0)); + let table_rows: String = rows .iter() .map(|(hash, subject, from, received_at)| {