Compare commits

...

16 Commits

Author SHA1 Message Date
phundrak 7fe75bb559 style: format code
Publish Docker Images / build-docker (push) Successful in 5m38s
Publish Docker Images / coverage-and-sonar (push) Successful in 9m35s
Publish Docker Images / push-docker (push) Successful in 37s
2026-06-02 01:50:31 +02:00
phundrak b6d7c50a38 chore(audit): deny wildcard versions in Cargo.toml 2026-06-02 01:50:31 +02:00
phundrak 002ff9a1c5 feat(RateLimit): add Retry-After header for 429 errors 2026-06-02 01:50:31 +02:00
phundrak 6bc14c7429 fix(health): move test to dedicated test mod 2026-06-02 01:50:31 +02:00
phundrak 03592c1e83 refactor(RateLimitConfig): replace magic values with struct method 2026-06-02 01:50:31 +02:00
phundrak 6199e73e59 feat(contact): sanitize user-submitted data 2026-06-02 01:50:31 +02:00
phundrak 3c65e1d83d fix: typo 2026-06-02 01:50:31 +02:00
phundrak 7294cd7651 feat(logs): only activate json or pretty logs one at a time 2026-06-02 01:50:31 +02:00
phundrak 123c0d17ed refactor: simplify code 2026-06-02 01:50:31 +02:00
phundrak 7e074888a6 fix(contact): sanatize user-supplied data in logs 2026-06-02 01:50:31 +02:00
phundrak 215ac75721 fix(logs): make tracing target consistent 2026-06-02 01:50:31 +02:00
phundrak 4d3432e92f refactor: better value cloning 2026-06-02 01:50:31 +02:00
phundrak e3aaf05838 fix(RateLimit): apply rate limiting based on client IP 2026-06-02 01:50:31 +02:00
phundrak bb4e230c0d feat(settings): proper CORS in production
If the backend starts in production mode with no `frontend_url` is set,
immediately panic and stop.
2026-06-02 01:50:31 +02:00
phundrak afd399b84f feat(SMTP): disallow unencrypted SMTP with credentials 2026-06-02 01:50:31 +02:00
phundrak b923f3bdb0 feat(OpenAPI): disable Swagger and OpenAPI specs in prod 2026-06-02 01:50:31 +02:00
9 changed files with 194 additions and 57 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ registries = []
[bans] [bans]
multiple-versions = "allow" multiple-versions = "allow"
wildcards = "allow" wildcards = "deny"
highlight = "all" highlight = "all"
workspace-default-features = "allow" workspace-default-features = "allow"
external-default-features = "allow" external-default-features = "allow"
+30 -16
View File
@@ -8,11 +8,13 @@ use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
use governor::{ use governor::{
Quota, RateLimiter, Quota, RateLimiter,
clock::DefaultClock, clock::{Clock, DefaultClock},
state::{InMemoryState, NotKeyed}, state::keyed::DefaultKeyedStateStore,
}; };
use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result}; use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result};
type BakitRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
/// Rate limiting configuration. /// Rate limiting configuration.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RateLimitConfig { pub struct RateLimitConfig {
@@ -37,17 +39,26 @@ impl RateLimitConfig {
} }
} }
/// Return default values for disabling rate limiting.
#[must_use]
pub const fn disabled() -> Self {
Self {
burst_size: u32::MAX,
per_seconds: 1,
}
}
/// Creates a rate limiter from this configuration. /// Creates a rate limiter from this configuration.
/// ///
/// # Panics /// # Panics
/// ///
/// Panics if `burst_size` is zero. /// Panics if `burst_size` is zero.
#[must_use] #[must_use]
pub fn create_limiter(&self) -> RateLimiter<NotKeyed, InMemoryState, DefaultClock> { pub fn create_limiter(&self) -> BakitRateLimiter {
let quota = Quota::with_period(Duration::from_secs(self.per_seconds)) let quota = Quota::with_period(Duration::from_secs(self.per_seconds))
.expect("Failed to create quota") .expect("Failed to create quota")
.allow_burst(NonZeroU32::new(self.burst_size).expect("Burst size must be non-zero")); .allow_burst(NonZeroU32::new(self.burst_size).expect("Burst size must be non-zero"));
RateLimiter::direct(quota) RateLimiter::keyed(quota)
} }
} }
@@ -60,7 +71,7 @@ impl Default for RateLimitConfig {
/// Middleware for rate limiting based on IP address. /// Middleware for rate limiting based on IP address.
pub struct RateLimit { pub struct RateLimit {
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>, limiter: Arc<BakitRateLimiter>,
} }
impl RateLimit { impl RateLimit {
@@ -87,7 +98,7 @@ impl<E: Endpoint> Middleware<E> for RateLimit {
/// The endpoint wrapper that performs rate limiting checks. /// The endpoint wrapper that performs rate limiting checks.
pub struct RateLimitEndpoint<E> { pub struct RateLimitEndpoint<E> {
endpoint: E, endpoint: E,
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>, limiter: Arc<BakitRateLimiter>,
} }
impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> { impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
@@ -95,20 +106,22 @@ impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
async fn call(&self, req: Request) -> Result<Self::Output> { async fn call(&self, req: Request) -> Result<Self::Output> {
// Check rate limit // Check rate limit
if self.limiter.check().is_err() { let client_ip =
let client_ip = Self::get_client_ip(&req) Self::get_client_ip(&req).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
.map_or_else(|| "unknown".to_string(), |ip| ip.to_string()); if let Err(negative) = self.limiter.check_key(&client_ip) {
tracing::event!( tracing::event!(
target: "backend::middleware::rate_limit", target: "backend::middleware::rate_limit",
tracing::Level::WARN, tracing::Level::WARN,
client_ip = %client_ip, client_ip = %client_ip,
"Rate limit exceeded" "Rate limit exceeded"
); );
let clock = DefaultClock::default();
return Err(Error::from_status( let wait = negative.wait_time_from(clock.now());
poem::http::StatusCode::TOO_MANY_REQUESTS, let response = Response::builder()
)); .status(poem::http::StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", wait.as_secs().to_string())
.finish();
return Err(Error::from_response(response));
} }
// Process the request // Process the request
@@ -148,14 +161,15 @@ mod tests {
fn rate_limit_config_creates_limiter() { fn rate_limit_config_creates_limiter() {
let config = RateLimitConfig::new(5, 1); let config = RateLimitConfig::new(5, 1);
let limiter = config.create_limiter(); let limiter = config.create_limiter();
let ip = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
// First 5 requests should succeed // First 5 requests should succeed
for _ in 0..5 { for _ in 0..5 {
assert!(limiter.check().is_ok()); assert!(limiter.check_key(&ip).is_ok());
} }
// 6th request should fail // 6th request should fail
assert!(limiter.check().is_err()); assert!(limiter.check_key(&ip).is_err());
} }
#[tokio::test] #[tokio::test]
+4 -3
View File
@@ -89,15 +89,16 @@ impl std::fmt::Display for ContactError {
/// If no specific field can be identified, returns a generic `ValidationError`. /// If no specific field can be identified, returns a generic `ValidationError`.
impl From<ValidationErrors> for ContactError { impl From<ValidationErrors> for ContactError {
fn from(value: ValidationErrors) -> Self { fn from(value: ValidationErrors) -> Self {
if validator::ValidationErrors::has_error(&Err(value.clone()), "name") { let errors = value.field_errors();
if errors.contains_key("name") {
return Self::ValidationNameError("backend.contact.errors.validation.name".to_owned()); return Self::ValidationNameError("backend.contact.errors.validation.name".to_owned());
} }
if validator::ValidationErrors::has_error(&Err(value.clone()), "email") { if errors.contains_key("email") {
return Self::ValidationEmailError( return Self::ValidationEmailError(
"backend.contact.errors.validation.email".to_owned(), "backend.contact.errors.validation.email".to_owned(),
); );
} }
if validator::ValidationErrors::has_error(&Err(value), "message") { if errors.contains_key("message") {
return Self::ValidationMessageError( return Self::ValidationMessageError(
"backend.contact.errors.validation.message".to_owned(), "backend.contact.errors.validation.message".to_owned(),
); );
+103 -6
View File
@@ -18,6 +18,23 @@ use crate::settings::{EmailSettings, Starttls};
pub mod errors; pub mod errors;
use errors::ContactError; 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 { impl TryFrom<&EmailSettings> for SmtpTransport {
type Error = lettre::transport::smtp::Error; type Error = lettre::transport::smtp::Error;
@@ -72,6 +89,14 @@ struct ContactRequest {
honeypot: Option<String>, honeypot: Option<String>,
} }
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 { impl TryFrom<&ContactRequest> for lettre::message::Mailbox {
type Error = ContactError; type Error = ContactError;
@@ -160,7 +185,8 @@ impl ContactApi {
body: Json<ContactRequest>, body: Json<ContactRequest>,
remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>, remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>,
) -> ContactApiResponse { ) -> ContactApiResponse {
let body = body.0; let mut body = body.0;
body.sanitize();
if let Some(ref honeypot) = body.honeypot if let Some(ref honeypot) = body.honeypot
&& !honeypot.trim().is_empty() && !honeypot.trim().is_empty()
{ {
@@ -182,9 +208,10 @@ impl ContactApi {
Ok(()) => { Ok(()) => {
tracing::event!( tracing::event!(
target: "backend::contact", target: "backend::contact",
tracing::Level::INFO, "Message from \"{} <{}>\" sent successfully", tracing::Level::INFO,
body.name, name = %body.name,
body.email email = %body.email,
"Contact form message sent successfully"
); );
ContactApiResponse::Ok(ContactResponse::success().into()) ContactApiResponse::Ok(ContactResponse::success().into())
} }
@@ -216,11 +243,11 @@ impl ContactApi {
"New contact form submission:\n\nName: {}\nEmail: {}\n\nMessage:\n{}", "New contact form submission:\n\nName: {}\nEmail: {}\n\nMessage:\n{}",
request.name, request.email, request.message request.name, request.email, request.message
); );
tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body); tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body);
let email = Message::builder() let email = Message::builder()
.from(self.settings.try_sender_into_mailbox()?) .from(self.settings.try_sender_into_mailbox()?)
.reply_to(request.try_into()?) .reply_to(request.try_into()?)
.to(self.settings.try_recpient_into_mailbox()?) .to(self.settings.try_recipient_into_mailbox()?)
.subject(format!("Contact Form: {}", request.name)) .subject(format!("Contact Form: {}", request.name))
.header(ContentType::TEXT_PLAIN) .header(ContentType::TEXT_PLAIN)
.body(email_body)?; .body(email_body)?;
@@ -1001,4 +1028,74 @@ mod tests {
e => panic!("Expected CouldNotSendEmail, got {e:?}"), 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, "Привіт");
}
} }
+3
View File
@@ -28,6 +28,8 @@ impl HealthApi {
} }
} }
#[cfg(test)]
mod tests {
#[tokio::test] #[tokio::test]
async fn health_check_works() { async fn health_check_works() {
let app = crate::get_test_app(); let app = crate::get_test_app();
@@ -36,3 +38,4 @@ async fn health_check_works() {
resp.assert_status_is_ok(); resp.assert_status_is_ok();
resp.assert_text("").await; resp.assert_text("").await;
} }
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub(crate) struct Api {
impl From<&Settings> for Api { impl From<&Settings> for Api {
fn from(value: &Settings) -> Self { fn from(value: &Settings) -> Self {
let contact = contact::ContactApi::from(value.clone().email); let contact = contact::ContactApi::from(value.email.clone());
let health = health::HealthApi; let health = health::HealthApi;
let meta = meta::MetaApi::from(&value.application); let meta = meta::MetaApi::from(&value.application);
Self { Self {
+3 -3
View File
@@ -168,7 +168,7 @@ impl EmailSettings {
/// - The email address format is invalid /// - The email address format is invalid
/// - The email address contains invalid characters /// - The email address contains invalid characters
/// - The email address structure is malformed /// - The email address structure is malformed
pub fn try_recpient_into_mailbox( pub fn try_recipient_into_mailbox(
&self, &self,
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> { ) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
Ok(self.recipient.parse::<lettre::message::Mailbox>()?) Ok(self.recipient.parse::<lettre::message::Mailbox>()?)
@@ -696,7 +696,7 @@ mod tests {
tls: false, tls: false,
}; };
let result = settings.try_recpient_into_mailbox(); let result = settings.try_recipient_into_mailbox();
assert!(result.is_ok()); assert!(result.is_ok());
let mailbox = result.unwrap(); let mailbox = result.unwrap();
assert_eq!(mailbox.email.to_string(), "recipient@example.com"); assert_eq!(mailbox.email.to_string(), "recipient@example.com");
@@ -715,7 +715,7 @@ mod tests {
tls: false, tls: false,
}; };
let result = settings.try_recpient_into_mailbox(); let result = settings.try_recipient_into_mailbox();
assert!(result.is_err()); assert!(result.is_err());
} }
} }
+36 -11
View File
@@ -10,6 +10,7 @@ use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route}; use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService; use poem_openapi::OpenApiService;
use crate::settings::Starttls;
use crate::{ use crate::{
middleware::rate_limit::{RateLimit, RateLimitConfig}, middleware::rate_limit::{RateLimit, RateLimitConfig},
route::Api, route::Api,
@@ -77,13 +78,22 @@ impl From<Application> for RunnableApplication {
"Rate limiting disabled (using very high limits)" "Rate limiting disabled (using very high limits)"
); );
// Use very high limits to effectively disable rate limiting // Use very high limits to effectively disable rate limiting
RateLimitConfig::new(u32::MAX, 1) RateLimitConfig::disabled()
};
let frontend_url = value.settings.frontend_url.clone();
let cors = if value.settings.debug {
Cors::new()
} else {
assert!(
!cfg!(test) || !frontend_url.is_empty(),
"CORS: frontend_url must be configured in production"
);
Cors::new().allow_origin(frontend_url)
}; };
let app = value let app = value
.app .app
.with(RateLimit::new(&rate_limit_config)) .with(RateLimit::new(&rate_limit_config))
.with(Cors::new()) .with(cors)
.data(value.settings); .data(value.settings);
let server = value.server; let server = value.server;
@@ -93,17 +103,32 @@ impl From<Application> for RunnableApplication {
impl Application { impl Application {
fn setup_app(settings: &Settings) -> poem::Route { fn setup_app(settings: &Settings) -> poem::Route {
Self::prevent_unencrypted_smtp_with_credentials(settings);
let api_service = OpenApiService::new( let api_service = OpenApiService::new(
Api::from(settings).apis(), Api::from(settings).apis(),
settings.application.clone().name, settings.application.name.clone(),
settings.application.clone().version, settings.application.version.clone(),
) )
.url_prefix("/api"); .url_prefix("/api");
let ui = api_service.swagger_ui(); let ui = api_service.swagger_ui();
poem::Route::new() let mut route = poem::Route::new().nest("/api", api_service.clone());
.nest("/api", api_service.clone()) if settings.debug {
.nest("/specs", api_service.spec_endpoint_yaml()) route = route
.nest("/", ui) .nest("/", ui)
.nest("/specs", api_service.spec_endpoint_yaml());
}
route
}
fn prevent_unencrypted_smtp_with_credentials(settings: &Settings) {
if !settings.email.tls
&& settings.email.starttls == Starttls::Never
&& !settings.email.user.is_empty()
&& settings.email.host != "localhost"
&& settings.email.host != "127.0.0.1"
{
panic!("Refusing to send SMTP credentials over cleartext to non-local host");
}
} }
fn setup_server( fn setup_server(
@@ -129,7 +154,7 @@ impl Application {
tcp_listener: Option<poem::listener::TcpListener<String>>, tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self { ) -> Self {
let port = settings.application.port; let port = settings.application.port;
let host = settings.application.clone().host; let host = settings.application.host.clone();
let app = Self::setup_app(&settings); let app = Self::setup_app(&settings);
let server = Self::setup_server(&settings, tcp_listener); let server = Self::setup_server(&settings, tcp_listener);
Self { Self {
@@ -149,8 +174,8 @@ impl Application {
/// Returns the host address the application is configured to bind to. /// Returns the host address the application is configured to bind to.
#[must_use] #[must_use]
pub fn host(&self) -> String { pub fn host(&self) -> &str {
self.host.clone() &self.host
} }
/// Returns the port the application is configured to bind to. /// Returns the port the application is configured to bind to.
+5 -8
View File
@@ -14,16 +14,13 @@ pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug { "debug" } else { "info" }.to_string(); let env_filter = if debug { "debug" } else { "info" }.to_string();
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter)); .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty(); let subscriber = tracing_subscriber::Registry::default().with(env_filter);
let subscriber = tracing_subscriber::Registry::default() let (stdout_log, json_log) = if debug {
.with(env_filter) (Some(tracing_subscriber::fmt::layer().pretty()), None)
.with(stdout_log);
let json_log = if debug {
None
} else { } else {
Some(tracing_subscriber::fmt::layer().json()) (None, Some(tracing_subscriber::fmt::layer().json()))
}; };
subscriber.with(json_log) subscriber.with(stdout_log).with(json_log)
} }
/// Initializes the global tracing subscriber. /// Initializes the global tracing subscriber.