Compare commits
2 commits
8daedbd5b6
...
d43ddb5e06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43ddb5e06 | ||
|
|
658381307d |
3 changed files with 18 additions and 41 deletions
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 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`.
|
||||||
|
|
@ -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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)| {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue