feat: sort by time

This commit is contained in:
Jet Pham 2026-03-05 13:22:19 -08:00
parent 658381307d
commit d43ddb5e06
No known key found for this signature in database
2 changed files with 3 additions and 41 deletions

View file

@ -1,4 +1,3 @@
use chrono::Utc;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use serde::Deserialize; use serde::Deserialize;
use sha2::Sha256; use sha2::Sha256;
@ -13,8 +12,6 @@ pub enum Error {
InvalidSignatureHeader, InvalidSignatureHeader,
#[error("signature verification failed")] #[error("signature verification failed")]
SignatureVerificationFailed, SignatureVerificationFailed,
#[error("timestamp too old (replay protection)")]
TimestampTooOld,
#[error("failed to parse webhook body: {0}")] #[error("failed to parse webhook body: {0}")]
ParseError(#[from] serde_json::Error), ParseError(#[from] serde_json::Error),
#[error("HMAC error: {0}")] #[error("HMAC error: {0}")]
@ -168,16 +165,9 @@ fn parse_signature_header(header: &str) -> Result<(i64, String), Error> {
/// 2. Construct the signed payload: `"{timestamp}.{raw_body}"` /// 2. Construct the signed payload: `"{timestamp}.{raw_body}"`
/// 3. Compute HMAC-SHA256 with the webhook secret /// 3. Compute HMAC-SHA256 with the webhook secret
/// 4. Constant-time compare /// 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> { pub fn verify_signature(raw_body: &str, signature_header: &str, secret: &str) -> Result<(), Error> {
let (timestamp, expected_sig) = parse_signature_header(signature_header)?; 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 // Construct signed payload
let payload = format!("{}.{}", timestamp, raw_body); let payload = format!("{}.{}", timestamp, raw_body);
@ -213,6 +203,7 @@ pub fn handle_webhook(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
#[test] #[test]
fn test_parse_signature_header() { fn test_parse_signature_header() {
@ -254,21 +245,4 @@ mod tests {
assert_eq!(event.email.id, "e90fef93-9549-4e17-b86f-295c13089645"); 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)
));
}
} }

View file

@ -62,15 +62,6 @@ fn map_webhook_error(e: mymx_sdk::Error) -> (StatusCode, Json<ErrorResponse>) {
}), }),
) )
} }
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) => { mymx_sdk::Error::HmacError(msg) => {
tracing::warn!("Webhook rejected: HMAC error: {}", msg); tracing::warn!("Webhook rejected: HMAC error: {}", msg);
( (
@ -218,7 +209,7 @@ async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
async fn index_handler(State(state): State<AppState>) -> Result<Html<String>, StatusCode> { async fn index_handler(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let emails: Vec<EmailRow> = sqlx::query_as::<_, EmailRow>( let emails: Vec<EmailRow> = sqlx::query_as::<_, EmailRow>(
"SELECT body, subject, from_address, received_at FROM emails", "SELECT body, subject, from_address, received_at FROM emails ORDER BY received_at DESC",
) )
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
@ -227,7 +218,7 @@ async fn index_handler(State(state): State<AppState>) -> Result<Html<String>, St
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let mut rows: Vec<(String, String, String, DateTime<Utc>)> = emails let rows: Vec<(String, String, String, DateTime<Utc>)> = emails
.into_iter() .into_iter()
.map(|email| { .map(|email| {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
@ -239,9 +230,6 @@ async fn index_handler(State(state): State<AppState>) -> Result<Html<String>, St
}) })
.collect(); .collect();
// Sort alphabetically by hash
rows.sort_by(|a, b| a.0.cmp(&b.0));
let table_rows: String = rows let table_rows: String = rows
.iter() .iter()
.map(|(hash, subject, from, received_at)| { .map(|(hash, subject, from, received_at)| {