use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use serde_json::{json, Value};
use tower::ServiceExt;
use crate::{
api,
config::{
ApiKeyConfig, AppConfig, LoggingConfig, MailConfig, RateLimitConfig, SecretString,
SecurityConfig, ServerConfig, SmtpConfig,
},
AppState,
};
fn test_config() -> AppConfig {
AppConfig {
server: ServerConfig {
bind_address: "127.0.0.1:0".into(),
max_request_body_bytes: 256, request_timeout_seconds: 5,
shutdown_timeout_seconds: 5,
concurrency_limit: 0,
tls_cert: None,
tls_key: None,
},
security: SecurityConfig {
require_auth: true,
trust_proxy_headers: false,
trusted_source_cidrs: vec![],
allowed_source_cidrs: vec![],
api_keys: vec![
ApiKeyConfig {
id: "enabled-key".into(),
secret: SecretString::new("valid-secret"),
enabled: true,
description: None,
allowed_recipient_domains: vec!["example.com".into()],
rate_limit_per_min: None,
allowed_recipients: vec![],
burst: 0,
mask_recipient: None,
},
ApiKeyConfig {
id: "disabled-key".into(),
secret: SecretString::new("disabled-secret"),
enabled: false,
description: None,
allowed_recipient_domains: vec![],
rate_limit_per_min: None,
allowed_recipients: vec![],
burst: 0,
mask_recipient: None,
},
],
},
mail: MailConfig {
default_from: "relay@example.com".into(),
default_from_name: None,
allowed_recipient_domains: vec!["example.com".into()],
max_subject_chars: 255,
max_body_bytes: 200, max_recipients: 10,
max_attachments: 5,
max_attachment_bytes: 10 * 1024 * 1024,
max_bulk_messages: 10,
},
smtp: SmtpConfig {
mode: "smtp".into(),
host: "127.0.0.1".into(),
port: 1, connect_timeout_seconds: 1,
submission_timeout_seconds: 1,
auth_user: None,
auth_password: None,
pipe_command: "/usr/sbin/sendmail".into(),
tls: "none".into(),
bulk_concurrency: 5,
},
rate_limit: RateLimitConfig {
global_per_min: 60,
per_ip_per_min: 20,
per_key_per_min: 30,
global_burst: 5,
per_ip_burst: 5,
per_key_burst: 5,
burst_size: 0,
ip_table_size: 100,
},
logging: LoggingConfig {
format: "text".into(),
level: "error".into(), mask_recipient: true,
},
status: Default::default(),
}
}
fn test_router() -> axum::Router {
let state = AppState::new(test_config());
api::build_router(state)
}
async fn send_request(
router: &axum::Router,
auth: Option<&str>,
body: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri("/v1/send")
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = auth {
builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
let req = builder.body(Body::from(body.to_string())).unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(json!({}));
(status, json)
}
fn valid_body() -> Value {
json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello."
})
}
#[tokio::test]
async fn sec_001_no_auth_header_returns_401() {
let router = test_router();
let (status, body) = send_request(&router, None, valid_body()).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "body={body}");
assert_eq!(body["code"], "unauthorized");
}
#[tokio::test]
async fn sec_002_wrong_token_returns_403() {
let router = test_router();
let (status, body) = send_request(&router, Some("completely-wrong"), valid_body()).await;
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
assert_eq!(body["code"], "forbidden");
}
#[tokio::test]
async fn sec_003_disabled_key_returns_403() {
let router = test_router();
let (status, body) = send_request(&router, Some("disabled-secret"), valid_body()).await;
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
assert_eq!(body["code"], "forbidden");
}
#[tokio::test]
async fn sec_008_unknown_field_from_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"from": "evil@evil.com"
});
let (status, body) = send_request(&router, Some("valid-secret"), bad).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "body={body}");
}
#[tokio::test]
async fn sec_009_unknown_field_bcc_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"bcc": "spy@evil.com"
});
let (status, _) = send_request(&router, Some("valid-secret"), bad).await;
assert!(
status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::BAD_REQUEST,
"expected 422 or 400, got {status}"
);
}
#[tokio::test]
async fn sec_010_unknown_field_headers_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"headers": {"X-Custom": "injected"}
});
let (status, _) = send_request(&router, Some("valid-secret"), bad).await;
assert!(
status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::BAD_REQUEST,
"expected 422 or 400, got {status}"
);
}
#[tokio::test]
async fn sec_011_oversized_request_body_returns_413() {
let router = test_router();
let giant = "x".repeat(300);
let req = Request::builder()
.method("POST")
.uri("/v1/send")
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "Bearer valid-secret")
.body(Body::from(giant))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn from_address_cannot_be_overridden_via_extra_field() {
let router = test_router();
let with_from = json!({
"to": "user@example.com",
"subject": "Hi",
"body": "Text.",
"from": "spoofed@attacker.com"
});
let (status, _) = send_request(&router, Some("valid-secret"), with_from).await;
assert_ne!(
status,
StatusCode::ACCEPTED,
"A request with a 'from' field must never result in 202 Accepted"
);
}
#[cfg(test)]
mod validation_tests {
use crate::{
auth::AuthContext,
config::{
ApiKeyConfig, AppConfig, LoggingConfig, MailConfig, RateLimitConfig, SecretString,
SecurityConfig, ServerConfig, SmtpConfig,
},
error::AppError,
validation::{validate_mail_request, MailRequest},
};
use std::net::IpAddr;
fn make_auth(key_id: &str) -> AuthContext {
AuthContext {
key_id: key_id.to_string(),
client_ip: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
key_rate_limit_per_min: None,
key_burst: 0,
}
}
fn minimal_config() -> AppConfig {
AppConfig {
server: ServerConfig {
bind_address: "127.0.0.1:8080".into(),
max_request_body_bytes: 65536,
request_timeout_seconds: 30,
shutdown_timeout_seconds: 30,
concurrency_limit: 0,
tls_cert: None,
tls_key: None,
},
security: SecurityConfig {
require_auth: true,
trust_proxy_headers: false,
trusted_source_cidrs: vec![],
allowed_source_cidrs: vec![],
api_keys: vec![ApiKeyConfig {
id: "test-key".into(),
secret: SecretString::new("tok"),
enabled: true,
description: None,
allowed_recipient_domains: vec![],
allowed_recipients: vec![],
rate_limit_per_min: None,
burst: 0,
mask_recipient: None,
}],
},
mail: MailConfig {
default_from: "relay@example.com".into(),
default_from_name: None,
allowed_recipient_domains: vec![],
max_subject_chars: 200,
max_body_bytes: 1_000_000,
max_recipients: 10,
max_attachments: 5,
max_attachment_bytes: 10 * 1024 * 1024,
max_bulk_messages: 10,
},
smtp: SmtpConfig {
mode: "smtp".into(),
host: "127.0.0.1".into(),
port: 25,
connect_timeout_seconds: 5,
submission_timeout_seconds: 30,
auth_user: None,
auth_password: None,
pipe_command: "/usr/sbin/sendmail".into(),
tls: "none".into(),
bulk_concurrency: 5,
},
rate_limit: RateLimitConfig {
global_per_min: 60,
per_ip_per_min: 20,
per_key_per_min: 30,
global_burst: 5,
per_ip_burst: 5,
per_key_burst: 5,
burst_size: 0,
ip_table_size: 100,
},
logging: LoggingConfig {
format: "text".into(),
level: "info".into(),
mask_recipient: false,
},
status: Default::default(),
}
}
fn minimal_request() -> MailRequest {
MailRequest {
to: crate::validation::Recipients(vec!["user@example.com".into()]),
subject: "Hello".into(),
body: "Test body".into(),
from_name: None,
reply_to: None,
body_html: None,
cc: None,
attachments: None,
metadata: None,
}
}
#[test]
fn valid_request_passes() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = minimal_request();
assert!(validate_mail_request(req, &cfg, &auth).is_ok());
}
#[test]
fn invalid_email_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["not-an-email".into()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn crlf_in_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: "Hello\r\nBcc: evil@x.com".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn empty_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: " ".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn oversized_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: "a".repeat(201),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn nul_in_body_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
body: "Hello\0world".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn crlf_in_from_name_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
from_name: Some("Evil\r\nBcc: attacker@evil.com".into()),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn disallowed_domain_rejected() {
let mut cfg = minimal_config();
cfg.mail.allowed_recipient_domains = vec!["allowed.com".into()];
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["user@other.com".into()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn allowed_domain_passes() {
let mut cfg = minimal_config();
cfg.mail.allowed_recipient_domains = vec!["example.com".into()];
let auth = make_auth("test-key");
let req = minimal_request(); assert!(validate_mail_request(req, &cfg, &auth).is_ok());
}
#[test]
fn per_key_domain_restriction_works() {
let mut cfg = minimal_config();
cfg.security.api_keys[0].allowed_recipient_domains = vec!["allowed.com".into()];
let auth = make_auth("test-key");
let req = minimal_request(); assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn metadata_client_request_id_extracted() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
metadata: Some(serde_json::json!({"request_id": "client-123"})),
..minimal_request()
};
let v = validate_mail_request(req, &cfg, &auth).unwrap();
assert_eq!(v.client_request_id.as_deref(), Some("client-123"));
}
#[test]
fn crlf_in_reply_to_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
for bad in &[
"user@example.com
Bcc: evil@evil.com",
"user@example.com
X-Header: injected",
] {
let req = MailRequest {
reply_to: Some(crate::validation::Recipients(vec![bad.to_string()])),
..minimal_request()
};
assert!(
matches!(validate_mail_request(req, &cfg, &auth), Err(AppError::Validation(_))),
"expected Validation error for reply_to={bad:?}"
);
}
}
#[test]
fn crlf_in_to_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["user@example.com\nBcc: attacker@evil.com".to_string()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn secret_string_redacted_in_debug() {
use crate::config::SecretString;
let s = SecretString::new("super-secret-token-value");
let debug = format!("{s:?}");
assert!(
!debug.contains("super-secret-token-value"),
"SecretString Debug must not expose secret; got: {debug}"
);
}
}