mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -1,13 +1,28 @@
|
||||
use base64::Engine;
|
||||
|
||||
use crate::handlers::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
||||
decrypt_or_migrate_smtp_password, smtp_password_binding, system_config_bool,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const SMTP_TIMEOUT_SECS: u64 = 30;
|
||||
const SMTP_MAX_HOST_BYTES: usize = 255;
|
||||
const SMTP_MAX_ADDRESS_BYTES: usize = 320;
|
||||
const SMTP_MAX_HEADER_VALUE_BYTES: usize = 512;
|
||||
const SMTP_MAX_USERNAME_BYTES: usize = 320;
|
||||
const SMTP_MAX_PASSWORD_BYTES: usize = 16 * 1024;
|
||||
const SMTP_MAX_STORED_PASSWORD_BYTES: usize = 64 * 1024;
|
||||
const SMTP_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
|
||||
const SMTP_MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const SMTP_MAX_DIAGNOSTIC_BYTES: usize = 4096;
|
||||
// SMTP servers normally emit short ASCII status lines. Keep parser buffers
|
||||
// bounded even when the peer is untrusted or compromised; these limits apply
|
||||
// only to control responses, not to the message body being submitted.
|
||||
const SMTP_MAX_RESPONSE_LINE_BYTES: usize = 16 * 1024;
|
||||
const SMTP_MAX_RESPONSE_BYTES: usize = 256 * 1024;
|
||||
const SMTP_MAX_RESPONSE_LINES: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SmtpDeliveryConfig {
|
||||
pub(crate) host: String,
|
||||
pub(crate) port: u16,
|
||||
@@ -19,7 +34,7 @@ pub(crate) struct SmtpDeliveryConfig {
|
||||
pub(crate) from_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ComposedEmail {
|
||||
pub(crate) to_email: String,
|
||||
pub(crate) subject: String,
|
||||
@@ -27,6 +42,26 @@ pub(crate) struct ComposedEmail {
|
||||
pub(crate) text_body: String,
|
||||
}
|
||||
|
||||
fn bounded_system_config_string(
|
||||
field: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<String>, GatewayError> {
|
||||
let Some(serde_json::Value::String(raw)) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.len() > max_bytes {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} exceeds the allowed size"
|
||||
)));
|
||||
}
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_smtp_delivery_config(
|
||||
state: &AppState,
|
||||
) -> Result<Option<SmtpDeliveryConfig>, GatewayError> {
|
||||
@@ -34,10 +69,16 @@ pub(crate) async fn read_smtp_delivery_config(
|
||||
let smtp_from_email = state
|
||||
.read_system_config_json_value("smtp_from_email")
|
||||
.await?;
|
||||
let Some(host) = system_config_string(smtp_host.as_ref()) else {
|
||||
let Some(host) = bounded_system_config_string("host", smtp_host.as_ref(), SMTP_MAX_HOST_BYTES)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(from_email) = system_config_string(smtp_from_email.as_ref()) else {
|
||||
let Some(from_email) = bounded_system_config_string(
|
||||
"from_email",
|
||||
smtp_from_email.as_ref(),
|
||||
SMTP_MAX_ADDRESS_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -50,20 +91,43 @@ pub(crate) async fn read_smtp_delivery_config(
|
||||
.read_system_config_json_value("smtp_from_name")
|
||||
.await?;
|
||||
|
||||
let password = system_config_string(smtp_password.as_ref()).map(|value| {
|
||||
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||
});
|
||||
let port = system_config_u16(smtp_port.as_ref(), 587);
|
||||
let user = bounded_system_config_string("user", smtp_user.as_ref(), SMTP_MAX_USERNAME_BYTES)?;
|
||||
let use_tls = system_config_bool(smtp_use_tls.as_ref(), true);
|
||||
let use_ssl = system_config_bool(smtp_use_ssl.as_ref(), false);
|
||||
let password = match (
|
||||
bounded_system_config_string(
|
||||
"stored_password",
|
||||
smtp_password.as_ref(),
|
||||
SMTP_MAX_STORED_PASSWORD_BYTES,
|
||||
)?,
|
||||
smtp_password_binding(&host, port, user.as_deref(), use_tls, use_ssl),
|
||||
) {
|
||||
(Some(value), Some(binding)) => {
|
||||
Some(decrypt_or_migrate_smtp_password(state, &binding, value).await?)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(GatewayError::Internal(
|
||||
"SMTP password binding is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
(None, _) => None,
|
||||
};
|
||||
|
||||
Ok(Some(SmtpDeliveryConfig {
|
||||
host,
|
||||
port: system_config_u16(smtp_port.as_ref(), 587),
|
||||
user: system_config_string(smtp_user.as_ref()),
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
use_tls: system_config_bool(smtp_use_tls.as_ref(), true),
|
||||
use_ssl: system_config_bool(smtp_use_ssl.as_ref(), false),
|
||||
use_tls,
|
||||
use_ssl,
|
||||
from_email,
|
||||
from_name: system_config_string(smtp_from_name.as_ref())
|
||||
.unwrap_or_else(|| "Aether".to_string()),
|
||||
from_name: bounded_system_config_string(
|
||||
"from_name",
|
||||
smtp_from_name.as_ref(),
|
||||
SMTP_MAX_HEADER_VALUE_BYTES,
|
||||
)?
|
||||
.unwrap_or_else(|| "Aether".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -71,17 +135,150 @@ pub(crate) async fn send_smtp_email(
|
||||
config: SmtpDeliveryConfig,
|
||||
email: ComposedEmail,
|
||||
) -> Result<(), GatewayError> {
|
||||
validate_smtp_delivery_inputs(&config, &email)?;
|
||||
tokio::task::spawn_blocking(move || send_smtp_email_blocking(config, email))
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
}
|
||||
|
||||
pub(crate) async fn probe_smtp_connection(config: SmtpDeliveryConfig) -> Result<(), GatewayError> {
|
||||
validate_smtp_config(&config)?;
|
||||
tokio::task::spawn_blocking(move || probe_smtp_connection_blocking(config))
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
}
|
||||
|
||||
fn validate_smtp_control_field(field: &str, value: &str) -> Result<(), GatewayError> {
|
||||
if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} contains forbidden control characters"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_smtp_bounded_field(
|
||||
field: &str,
|
||||
value: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<(), GatewayError> {
|
||||
validate_smtp_control_field(field, value)?;
|
||||
if value.len() > max_bytes {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} exceeds the allowed size"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_smtp_body_field(field: &str, value: &str) -> Result<(), GatewayError> {
|
||||
// Bodies are base64 encoded before DATA is written, so line breaks and
|
||||
// tabs are valid content. NUL is still rejected because it is not valid
|
||||
// textual mail content and can confuse downstream gateways.
|
||||
if value.bytes().any(|byte| byte == 0) {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} contains a forbidden NUL byte"
|
||||
)));
|
||||
}
|
||||
if value.len() > SMTP_MAX_BODY_BYTES {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} exceeds the allowed size"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_smtp_address(field: &str, value: &str) -> Result<(), GatewayError> {
|
||||
validate_smtp_bounded_field(field, value, SMTP_MAX_ADDRESS_BYTES)?;
|
||||
if value.is_empty() || value.trim() != value || value.chars().any(char::is_whitespace) {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} must be a single mailbox address"
|
||||
)));
|
||||
}
|
||||
// Addresses are inserted inside SMTP angle brackets. Reject delimiters
|
||||
// that could turn one envelope/header value into multiple fields.
|
||||
if value
|
||||
.bytes()
|
||||
.any(|byte| matches!(byte, b'<' | b'>' | b',' | b';' | b'"' | b'\\'))
|
||||
{
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} contains invalid mailbox delimiters"
|
||||
)));
|
||||
}
|
||||
let Some((local, domain)) = value.split_once('@') else {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} must contain a mailbox domain"
|
||||
)));
|
||||
};
|
||||
if local.is_empty() || domain.is_empty() || domain.contains('@') {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"smtp {field} must contain a valid mailbox domain"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_smtp_auth_config(config: &SmtpDeliveryConfig) -> Result<(), GatewayError> {
|
||||
let username = config.user.as_deref().map(str::trim);
|
||||
let has_username = username.is_some_and(|value| !value.is_empty());
|
||||
let has_password = config.password.is_some();
|
||||
if has_username != has_password {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp username and password must be configured together".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(username) = username.filter(|value| !value.is_empty()) {
|
||||
validate_smtp_bounded_field("user", username, SMTP_MAX_USERNAME_BYTES)?;
|
||||
if !config.use_tls && !config.use_ssl {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp authentication requires TLS or SSL encryption".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(password) = config.password.as_deref() {
|
||||
validate_smtp_bounded_field("password", password, SMTP_MAX_PASSWORD_BYTES)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_smtp_config(config: &SmtpDeliveryConfig) -> Result<(), GatewayError> {
|
||||
validate_smtp_bounded_field("host", &config.host, SMTP_MAX_HOST_BYTES)?;
|
||||
if config.host.is_empty() || config.host.trim() != config.host {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp host must not be empty or padded".to_string(),
|
||||
));
|
||||
}
|
||||
if config.host.chars().any(char::is_whitespace) {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp host contains invalid whitespace".to_string(),
|
||||
));
|
||||
}
|
||||
if config.port == 0 {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp port must be non-zero".to_string(),
|
||||
));
|
||||
}
|
||||
if config.use_tls && config.use_ssl {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp TLS and SSL modes cannot both be enabled".to_string(),
|
||||
));
|
||||
}
|
||||
validate_smtp_address("from_email", &config.from_email)?;
|
||||
validate_smtp_bounded_field("from_name", &config.from_name, SMTP_MAX_HEADER_VALUE_BYTES)?;
|
||||
validate_smtp_auth_config(config)
|
||||
}
|
||||
|
||||
fn validate_smtp_delivery_inputs(
|
||||
config: &SmtpDeliveryConfig,
|
||||
email: &ComposedEmail,
|
||||
) -> Result<(), GatewayError> {
|
||||
validate_smtp_config(config)?;
|
||||
validate_smtp_address("to_email", &email.to_email)?;
|
||||
validate_smtp_bounded_field("subject", &email.subject, SMTP_MAX_HEADER_VALUE_BYTES)?;
|
||||
validate_smtp_body_field("html_body", &email.html_body)?;
|
||||
validate_smtp_body_field("text_body", &email.text_body)
|
||||
}
|
||||
|
||||
pub(crate) fn system_config_u16(value: Option<&serde_json::Value>, default: u16) -> u16 {
|
||||
match value {
|
||||
Some(serde_json::Value::Number(value)) => value
|
||||
@@ -132,8 +329,42 @@ fn resolve_server_name(host: &str) -> Result<rustls::pki_types::ServerName<'stat
|
||||
}
|
||||
|
||||
fn connect_tcp_stream(config: &SmtpDeliveryConfig) -> Result<std::net::TcpStream, GatewayError> {
|
||||
let stream = std::net::TcpStream::connect((config.host.as_str(), config.port))
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
use std::net::ToSocketAddrs;
|
||||
let addresses = (config.host.as_str(), config.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
.take(16)
|
||||
.collect::<Vec<_>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp host did not resolve to an address".to_string(),
|
||||
));
|
||||
}
|
||||
let deadline = std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS))
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let mut last_error = None;
|
||||
let mut stream = None;
|
||||
for address in addresses {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match std::net::TcpStream::connect_timeout(&address, remaining) {
|
||||
Ok(candidate) => {
|
||||
stream = Some(candidate);
|
||||
break;
|
||||
}
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
}
|
||||
let stream = stream.ok_or_else(|| {
|
||||
GatewayError::Internal(
|
||||
last_error
|
||||
.map(|err| err.to_string())
|
||||
.unwrap_or_else(|| "smtp connection timed out".to_string()),
|
||||
)
|
||||
})?;
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
@@ -155,38 +386,109 @@ fn wrap_tls_stream(
|
||||
|
||||
fn smtp_read_response<T: std::io::BufRead>(reader: &mut T) -> Result<(u16, String), GatewayError> {
|
||||
let mut message = String::new();
|
||||
let code = loop {
|
||||
let parsed_code;
|
||||
let continuation;
|
||||
let trimmed;
|
||||
{
|
||||
let mut line = String::new();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if bytes == 0 {
|
||||
let mut expected_code = None;
|
||||
for line_number in 0..SMTP_MAX_RESPONSE_LINES {
|
||||
let mut line = Vec::new();
|
||||
let bytes = read_smtp_response_line(reader, &mut line)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if bytes == 0 {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp connection closed unexpectedly".to_string(),
|
||||
));
|
||||
}
|
||||
if line.len() > SMTP_MAX_RESPONSE_LINE_BYTES {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp response line exceeds the allowed size".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Strip only the protocol line ending. The remaining bytes are kept
|
||||
// for diagnostics after validating that they are UTF-8.
|
||||
while matches!(line.last(), Some(b'\r' | b'\n')) {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() < 3 || !line[..3].iter().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(GatewayError::Internal("invalid smtp response".to_string()));
|
||||
}
|
||||
let parsed_code = u16::from(line[0] - b'0') * 100
|
||||
+ u16::from(line[1] - b'0') * 10
|
||||
+ u16::from(line[2] - b'0');
|
||||
if let Some(expected_code) = expected_code {
|
||||
if parsed_code != expected_code {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp connection closed unexpectedly".to_string(),
|
||||
"smtp response continuation code changed".to_string(),
|
||||
));
|
||||
}
|
||||
trimmed = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
if trimmed.len() < 3 {
|
||||
return Err(GatewayError::Internal("invalid smtp response".to_string()));
|
||||
}
|
||||
parsed_code = trimmed[..3]
|
||||
.parse::<u16>()
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
continuation = trimmed.as_bytes().get(3).copied() == Some(b'-');
|
||||
} else {
|
||||
expected_code = Some(parsed_code);
|
||||
}
|
||||
let separator = line.get(3).copied().unwrap_or(b' ');
|
||||
if separator != b'-' && separator != b' ' {
|
||||
return Err(GatewayError::Internal("invalid smtp response".to_string()));
|
||||
}
|
||||
|
||||
let trimmed = std::str::from_utf8(&line)
|
||||
.map_err(|_| GatewayError::Internal("smtp response is not valid UTF-8".to_string()))?;
|
||||
let additional = trimmed.len() + usize::from(!message.is_empty());
|
||||
if message
|
||||
.len()
|
||||
.checked_add(additional)
|
||||
.map_or(true, |length| length > SMTP_MAX_RESPONSE_BYTES)
|
||||
{
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp response exceeds the allowed size".to_string(),
|
||||
));
|
||||
}
|
||||
if !message.is_empty() {
|
||||
message.push('\n');
|
||||
}
|
||||
message.push_str(&trimmed);
|
||||
if !continuation {
|
||||
break parsed_code;
|
||||
message.push_str(trimmed);
|
||||
|
||||
if separator != b'-' {
|
||||
return Ok((parsed_code, message));
|
||||
}
|
||||
};
|
||||
Ok((code, message))
|
||||
|
||||
if line_number + 1 == SMTP_MAX_RESPONSE_LINES {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp response has too many continuation lines".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(GatewayError::Internal(
|
||||
"smtp response has too many continuation lines".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Read one SMTP response line without allowing `BufRead::read_until` to
|
||||
/// allocate an attacker-controlled amount of memory before a size check.
|
||||
fn read_smtp_response_line<T: std::io::BufRead>(
|
||||
reader: &mut T,
|
||||
line: &mut Vec<u8>,
|
||||
) -> std::io::Result<usize> {
|
||||
loop {
|
||||
let buffered = reader.fill_buf()?;
|
||||
if buffered.is_empty() {
|
||||
return Ok(line.len());
|
||||
}
|
||||
let newline = buffered.iter().position(|byte| *byte == b'\n');
|
||||
let take = newline.map_or(buffered.len(), |index| index + 1);
|
||||
if line
|
||||
.len()
|
||||
.checked_add(take)
|
||||
.map_or(true, |length| length > SMTP_MAX_RESPONSE_LINE_BYTES)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"smtp response line exceeds the allowed size",
|
||||
));
|
||||
}
|
||||
line.extend_from_slice(&buffered[..take]);
|
||||
reader.consume(take);
|
||||
if newline.is_some() {
|
||||
return Ok(line.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp_expect<T: std::io::BufRead>(
|
||||
@@ -197,11 +499,42 @@ fn smtp_expect<T: std::io::BufRead>(
|
||||
if allowed_codes.contains(&code) {
|
||||
return Ok(message);
|
||||
}
|
||||
let message = sanitize_smtp_diagnostic(&message);
|
||||
Err(GatewayError::Internal(format!(
|
||||
"unexpected smtp response {code}: {message}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// SMTP responses are controlled by a remote server. Keep diagnostics useful
|
||||
/// while preventing terminal escapes, log/UI line injection, and oversized
|
||||
/// error payloads from crossing the API boundary.
|
||||
fn sanitize_smtp_diagnostic(message: &str) -> String {
|
||||
let mut sanitized = String::new();
|
||||
let mut previous_space = false;
|
||||
for character in message.chars() {
|
||||
if character == '\u{1b}' || character.is_control() {
|
||||
if !previous_space {
|
||||
sanitized.push(' ');
|
||||
previous_space = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if sanitized.len() + character.len_utf8() > SMTP_MAX_DIAGNOSTIC_BYTES {
|
||||
break;
|
||||
}
|
||||
if character.is_whitespace() {
|
||||
if !previous_space {
|
||||
sanitized.push(' ');
|
||||
previous_space = true;
|
||||
}
|
||||
} else {
|
||||
sanitized.push(character);
|
||||
previous_space = false;
|
||||
}
|
||||
}
|
||||
sanitized.trim().to_string()
|
||||
}
|
||||
|
||||
fn smtp_write_line<T: std::io::Write>(writer: &mut T, line: &str) -> Result<(), GatewayError> {
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
@@ -223,7 +556,11 @@ fn smtp_send_command<S: std::io::Read + std::io::Write>(
|
||||
smtp_expect(reader, allowed_codes)
|
||||
}
|
||||
|
||||
fn build_email_message(config: &SmtpDeliveryConfig, email: &ComposedEmail) -> String {
|
||||
fn build_email_message(
|
||||
config: &SmtpDeliveryConfig,
|
||||
email: &ComposedEmail,
|
||||
) -> Result<String, GatewayError> {
|
||||
validate_smtp_delivery_inputs(config, email)?;
|
||||
let boundary = format!("aether-{}", uuid::Uuid::new_v4().simple());
|
||||
let text_body =
|
||||
wrap_base64(&base64::engine::general_purpose::STANDARD.encode(email.text_body.as_bytes()));
|
||||
@@ -238,11 +575,17 @@ fn build_email_message(config: &SmtpDeliveryConfig, email: &ComposedEmail) -> St
|
||||
config.from_email
|
||||
)
|
||||
};
|
||||
format!(
|
||||
let message = format!(
|
||||
"From: {from_header}\r\nTo: <{to_email}>\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\nContent-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{text_body}--{boundary}\r\nContent-Type: text/html; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{html_body}--{boundary}--\r\n",
|
||||
to_email = email.to_email,
|
||||
subject = encode_mime_header(&email.subject),
|
||||
)
|
||||
);
|
||||
if message.len() > SMTP_MAX_MESSAGE_BYTES {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp message exceeds the allowed size".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn smtp_authenticate<S: std::io::Read + std::io::Write>(
|
||||
@@ -257,6 +600,11 @@ fn smtp_authenticate<S: std::io::Read + std::io::Write>(
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !config.use_tls && !config.use_ssl {
|
||||
return Err(GatewayError::Internal(
|
||||
"smtp authentication requires TLS or SSL encryption".to_string(),
|
||||
));
|
||||
}
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
smtp_send_command(reader, "AUTH LOGIN", &[334])?;
|
||||
smtp_send_command(
|
||||
@@ -277,6 +625,9 @@ fn smtp_deliver_message<S: std::io::Read + std::io::Write>(
|
||||
config: &SmtpDeliveryConfig,
|
||||
email: &ComposedEmail,
|
||||
) -> Result<(), GatewayError> {
|
||||
// Keep this check next to command construction for callers that bypass
|
||||
// the async delivery wrapper.
|
||||
validate_smtp_delivery_inputs(config, email)?;
|
||||
smtp_send_command(
|
||||
reader,
|
||||
&format!("MAIL FROM:<{}>", config.from_email),
|
||||
@@ -288,7 +639,7 @@ fn smtp_deliver_message<S: std::io::Read + std::io::Write>(
|
||||
&[250, 251],
|
||||
)?;
|
||||
smtp_send_command(reader, "DATA", &[354])?;
|
||||
let message = build_email_message(config, email);
|
||||
let message = build_email_message(config, email)?;
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(message.as_bytes())
|
||||
@@ -379,3 +730,135 @@ fn probe_smtp_connection_blocking(config: SmtpDeliveryConfig) -> Result<(), Gate
|
||||
let _ = smtp_send_command(&mut reader, "QUIT", &[221]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config() -> SmtpDeliveryConfig {
|
||||
SmtpDeliveryConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
user: Some("[email protected]".to_string()),
|
||||
password: Some("password".to_string()),
|
||||
use_tls: true,
|
||||
use_ssl: false,
|
||||
from_email: "[email protected]".to_string(),
|
||||
from_name: "Aether".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn email() -> ComposedEmail {
|
||||
ComposedEmail {
|
||||
to_email: "[email protected]".to_string(),
|
||||
subject: "Subject".to_string(),
|
||||
html_body: "<p>hello</p>".to_string(),
|
||||
text_body: "hello".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_crlf_in_smtp_envelope_and_header_fields() {
|
||||
let mut malicious_config = config();
|
||||
malicious_config.from_email =
|
||||
"[email protected]\r\nRCPT TO:<[email protected]>".to_string();
|
||||
let error = validate_smtp_delivery_inputs(&malicious_config, &email())
|
||||
.expect_err("CRLF in an envelope address must be rejected");
|
||||
assert!(format!("{error:?}").contains("from_email"));
|
||||
|
||||
let mut malicious_email = email();
|
||||
malicious_email.subject = "Subject\nX-Injected: yes".to_string();
|
||||
let error = validate_smtp_delivery_inputs(&config(), &malicious_email)
|
||||
.expect_err("CRLF in a header value must be rejected");
|
||||
assert!(format!("{error:?}").contains("subject"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_normal_smtp_values() {
|
||||
assert!(validate_smtp_delivery_inputs(&config(), &email()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_authentication_over_plaintext_smtp() {
|
||||
let mut insecure = config();
|
||||
insecure.use_tls = false;
|
||||
insecure.use_ssl = false;
|
||||
let error = validate_smtp_delivery_inputs(&insecure, &email())
|
||||
.expect_err("SMTP credentials must never be sent over plaintext");
|
||||
assert!(format!("{error:?}").contains("requires TLS or SSL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_mailboxes_and_non_protocol_delimiters() {
|
||||
let mut malicious = email();
|
||||
malicious.to_email = "[email protected]>\x01RCPT TO:<[email protected]>".to_string();
|
||||
let error = validate_smtp_delivery_inputs(&config(), &malicious)
|
||||
.expect_err("control characters and envelope delimiters must be rejected");
|
||||
assert!(format!("{error:?}").contains("to_email"));
|
||||
|
||||
let mut malformed = email();
|
||||
malformed.to_email = "not-an-email".to_string();
|
||||
assert!(validate_smtp_delivery_inputs(&config(), &malformed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_message_bodies_before_smtp_submission() {
|
||||
let mut oversized = email();
|
||||
oversized.html_body = "x".repeat(SMTP_MAX_BODY_BYTES + 1);
|
||||
let error = validate_smtp_delivery_inputs(&config(), &oversized)
|
||||
.expect_err("oversized message bodies must be rejected");
|
||||
assert!(format!("{error:?}").contains("html_body"));
|
||||
|
||||
let mut textual = email();
|
||||
textual.text_body = "line one\nline two\t✓".to_string();
|
||||
assert!(validate_smtp_delivery_inputs(&config(), &textual).is_ok());
|
||||
|
||||
textual.text_body.push('\0');
|
||||
assert!(validate_smtp_delivery_inputs(&config(), &textual).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_remote_response_diagnostics() {
|
||||
let mut reader = std::io::BufReader::new("550 bad\u{1b}[31m\r\n".as_bytes());
|
||||
let error = smtp_expect(&mut reader, &[250]).expect_err("unexpected response must fail");
|
||||
let GatewayError::Internal(message) = error else {
|
||||
panic!("expected internal SMTP error");
|
||||
};
|
||||
assert!(!message.contains('\u{1b}'));
|
||||
assert!(!message.contains('\n'));
|
||||
assert!(message.len() < SMTP_MAX_DIAGNOSTIC_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_response_rejects_non_ascii_status_prefix_without_panicking() {
|
||||
let mut reader = std::io::BufReader::new("é00 greeting\r\n".as_bytes());
|
||||
let error = smtp_read_response(&mut reader)
|
||||
.expect_err("a non-ASCII status prefix must be rejected");
|
||||
assert!(format!("{error:?}").contains("invalid smtp response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_response_rejects_oversized_lines_before_allocating_unbounded_memory() {
|
||||
let mut input = vec![b'2'; SMTP_MAX_RESPONSE_LINE_BYTES + 1];
|
||||
input.push(b'\n');
|
||||
let mut reader = std::io::BufReader::new(input.as_slice());
|
||||
let error = smtp_read_response(&mut reader).expect_err("oversized line must be rejected");
|
||||
assert!(format!("{error:?}").contains("response line exceeds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_response_bounds_continuation_lines_and_code_changes() {
|
||||
let repeated = (0..=SMTP_MAX_RESPONSE_LINES)
|
||||
.map(|_| "250-more\r\n")
|
||||
.collect::<String>();
|
||||
let mut reader = std::io::BufReader::new(repeated.as_bytes());
|
||||
let error = smtp_read_response(&mut reader)
|
||||
.expect_err("too many continuation lines must be rejected");
|
||||
assert!(format!("{error:?}").contains("too many continuation lines"));
|
||||
|
||||
let mut reader = std::io::BufReader::new("250-more\r\n550 done\r\n".as_bytes());
|
||||
let error = smtp_read_response(&mut reader)
|
||||
.expect_err("continuation response code changes must be rejected");
|
||||
assert!(format!("{error:?}").contains("continuation code changed"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user