From 079047a0d5f449b2c7cd19543bc552e9889ccdb6 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Tue, 2 Jun 2026 00:57:01 +0200 Subject: [PATCH] feat(contact): sanitize user-submitted data --- src/route/contact/mod.rs | 100 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/route/contact/mod.rs b/src/route/contact/mod.rs index b332cc4..e56a371 100644 --- a/src/route/contact/mod.rs +++ b/src/route/contact/mod.rs @@ -18,6 +18,23 @@ use crate::settings::{EmailSettings, Starttls}; pub mod errors; use errors::ContactError; +/// Strips control characters that could enable protocol injection +/// +/// When `keep_newlines` is true, `\n` is preserved (needed for +/// multi-line fields). For name and email fields, all control +/// characters are removed - no assumptions are made about valid name +/// *content*. +fn strip_control_chars(s: &str, keep_newlines: bool) -> String { + s.chars() + .filter(|c| { + if keep_newlines && (*c == '\n') { + return true; + } + !c.is_control() + }) + .collect() +} + impl TryFrom<&EmailSettings> for SmtpTransport { type Error = lettre::transport::smtp::Error; @@ -72,6 +89,14 @@ struct ContactRequest { honeypot: Option, } +impl ContactRequest { + fn sanitize(&mut self) { + self.name = strip_control_chars(&self.name, false); + self.email = strip_control_chars(&self.email, false); + self.message = strip_control_chars(&self.message, true); + } +} + impl TryFrom<&ContactRequest> for lettre::message::Mailbox { type Error = ContactError; @@ -160,7 +185,8 @@ impl ContactApi { body: Json, remote_addr: Option>, ) -> ContactApiResponse { - let body = body.0; + let mut body = body.0; + body.sanitize(); if let Some(ref honeypot) = body.honeypot && !honeypot.trim().is_empty() { @@ -1002,4 +1028,76 @@ mod tests { e => panic!("Expected CouldNotSendEmail, got {e:?}"), } } + + #[test] + fn strip_control_chars_removes_null_bytes() { + let result = strip_control_chars("John\x00Doe", false); + assert_eq!(result, "JohnDoe"); + } + + #[test] + fn contact_request_sanatize_strips_all_control_chars() { + let mut request = ContactRequest { + name: "John\x00Doe".into(), + email: "john\x00@example.com".into(), + message: "Test\x00message".into(), + honeypot: None, + }; + request.sanitize(); + assert_eq!(request.name, "JohnDoe"); + assert_eq!(request.email, "john@example.com"); + assert_eq!(request.message, "Testmessage"); + } + + #[test] + fn contact_request_sanitize_preserves_newlines_in_message() { + let mut request = ContactRequest { + name: "John\nDoe".into(), + email: "john@example.com".into(), + message: "Line 1\nLine 2\r\nLine 3".into(), + honeypot: None, + }; + request.sanitize(); + assert_eq!(request.name, "JohnDoe"); + assert_eq!(request.email, "john@example.com"); + assert_eq!(request.message, "Line 1\nLine 2\nLine 3"); + } + + #[test] + fn contact_request_sanatize_preserves_unicode_name() { + let mut request_jp = ContactRequest { + name: "田中さん".into(), + email: "tanaka@example.com".into(), + message: "こんにちは!".into(), + honeypot: None, + }; + request_jp.sanitize(); + assert_eq!(request_jp.name, "田中さん"); + assert_eq!(request_jp.email, "tanaka@example.com"); + assert_eq!(request_jp.message, "こんにちは!"); + + let mut request_ar = ContactRequest { + name: "عبدالله".into(), + email: "abdullah@example.com".into(), + message: "مرحباً".into(), + honeypot: None, + }; + request_ar.sanitize(); + assert_eq!(request_ar.name, "عبدالله"); + assert_eq!(request_ar.email, "abdullah@example.com"); + assert_eq!(request_ar.message, "مرحباً"); + + let mut request_uk = ContactRequest { + name: "Олексáндр".into(), + email: "oleksandr@example.com".into(), + message: "Привіт".into(), + honeypot: None, + }; + request_uk.sanitize(); + assert_eq!(request_uk.name, "Олексáндр"); + assert_eq!(request_uk.email, "oleksandr@example.com"); + assert_eq!(request_uk.message, "Привіт"); + } + + }