Compare commits

..

16 Commits

Author SHA1 Message Date
phundrak 2ae69935ee style: format code
Publish Docker Images / build-docker (push) Successful in 9m19s
Publish Docker Images / coverage-and-sonar (push) Successful in 9m58s
Publish Docker Images / push-docker (push) Successful in 37s
2026-06-02 01:26:10 +02:00
phundrak c067177dab chore(audit): deny wildcard versions in Cargo.toml
Publish Docker Images / push-docker (push) Has been cancelled
Publish Docker Images / coverage-and-sonar (push) Has been cancelled
Publish Docker Images / build-docker (push) Has been cancelled
2026-06-02 01:23:20 +02:00
phundrak cb047968c3 feat(RateLimit): add Retry-After header for 429 errors 2026-06-02 01:20:45 +02:00
phundrak 9c4532f940 fix(health): move test to dedicated test mod 2026-06-02 01:05:32 +02:00
phundrak 84cf3359aa refactor(RateLimitConfig): replace magic values with struct method 2026-06-02 01:04:26 +02:00
phundrak 079047a0d5 feat(contact): sanitize user-submitted data 2026-06-02 00:59:17 +02:00
phundrak 302f07f43f fix: typo 2026-06-02 00:58:47 +02:00
phundrak 38fe2fa452 feat(logs): only activate json or pretty logs one at a time 2026-06-02 00:57:58 +02:00
phundrak e919167d22 refactor: simplify code 2026-06-02 00:57:14 +02:00
phundrak 2059572344 fix(contact): sanatize user-supplied data in logs 2026-06-01 23:58:25 +02:00
phundrak 8abb3d660f fix(logs): make tracing target consistent 2026-06-01 23:55:48 +02:00
phundrak 2bfdf60e9e refactor: better value cloning 2026-06-01 23:54:33 +02:00
phundrak 4fb8bda761 fix(RateLimit): apply rate limiting based on client IP 2026-06-01 23:52:46 +02:00
phundrak 2370462a3e 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-01 23:35:31 +02:00
phundrak 5c200e1f89 feat(SMTP): disallow unencrypted SMTP with credentials 2026-06-01 23:23:39 +02:00
phundrak b31a5ccaf5 feat(OpenAPI): disable Swagger and OpenAPI specs in prod 2026-06-01 23:18:03 +02:00
8 changed files with 100 additions and 75 deletions
+2 -14
View File
@@ -40,21 +40,9 @@ jobs:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
skipPush: ${{ github.event_name == 'pull_request' }} skipPush: ${{ github.event_name == 'pull_request' }}
- name: Format Check
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just format-check
- name: Audit
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just audit
- name: Lint
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just lint-report
- name: Coverage - name: Coverage
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}" run: |
run: just coverage-ci nix develop --no-pure-eval --accept-flake-config --command just coverage
- name: Sonar analysis - name: Sonar analysis
uses: SonarSource/sonarqube-scan-action@v6 uses: SonarSource/sonarqube-scan-action@v6
+1 -1
View File
@@ -321,7 +321,7 @@ backend/
The contact form supports multiple SMTP configurations: The contact form supports multiple SMTP configurations:
- **Implicit TLS (SMTPS)** - typically port 465 - **Implicit TLS (SMTPS)** - typically port 465
- **STARTTLS (Always)** - typically port 587 - **STARTTLS (Always/Opportunistic)** - typically port 587
- **Unencrypted** (for local dev) - with or without authentication - **Unencrypted** (for local dev) - with or without authentication
The `SmtpTransport` is built dynamically from `EmailSettings` based on The `SmtpTransport` is built dynamically from `EmailSettings` based on
-4
View File
@@ -24,10 +24,6 @@ build-release:
lint: lint:
cargo clippy --all-targets cargo clippy --all-targets
lint-report:
mkdir -p coverage
cargo clippy --all-targets --message-format=json > coverage/clippy.json
release-build: release-build:
cargo build --release cargo build --release
+6 -3
View File
@@ -24,7 +24,7 @@ pub mod startup;
/// Logging and tracing setup /// Logging and tracing setup
pub mod telemetry; pub mod telemetry;
type MaybeListener = Option<std::net::TcpListener>; type MaybeListener = Option<poem::listener::TcpListener<String>>;
fn prepare(listener: MaybeListener) -> startup::Application { fn prepare(listener: MaybeListener) -> startup::Application {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
@@ -70,8 +70,11 @@ pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
} }
#[cfg(test)] #[cfg(test)]
fn make_random_tcp_listener() -> std::net::TcpListener { fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener") let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener");
let port = tcp_listener.local_addr().unwrap().port();
poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"))
} }
#[cfg(test)] #[cfg(test)]
+1 -1
View File
@@ -54,7 +54,7 @@ impl Error for ContactError {}
/// issues beyond the client's control. /// issues beyond the client's control.
impl From<lettre::transport::smtp::Error> for ContactError { impl From<lettre::transport::smtp::Error> for ContactError {
fn from(value: lettre::transport::smtp::Error) -> Self { fn from(value: lettre::transport::smtp::Error) -> Self {
tracing::event!(target: "backend::contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}")); tracing::event!(target: "contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}"));
Self::OtherError(value.to_string()) Self::OtherError(value.to_string())
} }
} }
+20 -3
View File
@@ -62,7 +62,7 @@ impl TryFrom<&EmailSettings> for SmtpTransport {
Ok(builder.credentials(creds).build()) Ok(builder.credentials(creds).build())
} }
} }
Starttls::Always => { Starttls::Opportunistic | Starttls::Always => {
// STARTTLS - typically port 587 // STARTTLS - typically port 587
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using STARTTLS"); tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using STARTTLS");
let creds = Credentials::new(settings.user.clone(), settings.password.clone()); let creds = Credentials::new(settings.user.clone(), settings.password.clone());
@@ -186,6 +186,7 @@ impl ContactApi {
remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>, remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>,
) -> ContactApiResponse { ) -> ContactApiResponse {
let mut 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()
{ {
@@ -197,7 +198,6 @@ impl ContactApi {
); );
return ContactApiResponse::Ok(ContactResponse::honeypot_response().into()); return ContactApiResponse::Ok(ContactResponse::honeypot_response().into());
} }
body.sanitize();
if let Err(e) = body.validate() { if let Err(e) = body.validate() {
return ContactApiResponse::BadRequest( return ContactApiResponse::BadRequest(
<validator::ValidationErrors as std::convert::Into<ContactResponse>>::into(e) <validator::ValidationErrors as std::convert::Into<ContactResponse>>::into(e)
@@ -251,7 +251,7 @@ impl ContactApi {
.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)?;
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}")); tracing::event!(target: "contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}"));
Ok(email) Ok(email)
} }
@@ -429,6 +429,23 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn smtp_transport_starttls_opportunistic() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
password: "password".to_string(),
from: "from@example.com".to_string(),
recipient: "to@example.com".to_string(),
tls: false,
starttls: Starttls::Opportunistic,
};
let result = SmtpTransport::try_from(&settings);
assert!(result.is_ok());
}
#[test] #[test]
fn smtp_transport_no_encryption_with_credentials() { fn smtp_transport_no_encryption_with_credentials() {
let settings = EmailSettings { let settings = EmailSettings {
+41 -5
View File
@@ -163,9 +163,8 @@ impl EmailSettings {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns a `ContactError` if the email address in the `from` /// Returns a `ContactError` if the email address in the `from` field cannot be parsed
/// field of `recipient` cannot be parsed into a valid mailbox. /// into a valid mailbox. This can occur if:
/// This can occur if:
/// - 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
@@ -197,6 +196,8 @@ pub enum Starttls {
/// Never use STARTTLS (unencrypted connection) /// Never use STARTTLS (unencrypted connection)
#[default] #[default]
Never, Never,
/// Use STARTTLS if available (opportunistic encryption)
Opportunistic,
/// Always use STARTTLS (required encryption) /// Always use STARTTLS (required encryption)
Always, Always,
} }
@@ -207,9 +208,10 @@ impl TryFrom<&str> for Starttls {
fn try_from(value: &str) -> Result<Self, Self::Error> { fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() { match value.to_lowercase().as_str() {
"off" | "no" | "never" => Ok(Self::Never), "off" | "no" | "never" => Ok(Self::Never),
"opportunistic" => Ok(Self::Opportunistic),
"yes" | "always" => Ok(Self::Always), "yes" | "always" => Ok(Self::Always),
other => Err(format!( other => Err(format!(
"{other} is not a supported option. Use either `yes` or `no`" "{other} is not a supported option. Use either `yes`, `no`, or `opportunistic`"
)), )),
} }
} }
@@ -232,6 +234,7 @@ impl std::fmt::Display for Starttls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self { let self_str = match self {
Self::Never => "never", Self::Never => "never",
Self::Opportunistic => "opportunistic",
Self::Always => "always", Self::Always => "always",
}; };
write!(f, "{self_str}") write!(f, "{self_str}")
@@ -249,7 +252,7 @@ impl<'de> serde::Deserialize<'de> for Starttls {
type Value = Starttls; type Value = Starttls;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', true, false)") formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', 'opportunistic', true, false)")
} }
fn visit_str<E>(self, value: &str) -> Result<Starttls, E> fn visit_str<E>(self, value: &str) -> Result<Starttls, E>
@@ -431,6 +434,13 @@ mod tests {
assert_eq!(result, Starttls::Always); assert_eq!(result, Starttls::Always);
} }
#[test]
fn startls_deserialize_from_string_opportunistic() {
let json = r#""opportunistic""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Opportunistic);
}
#[test] #[test]
fn startls_deserialize_from_bool() { fn startls_deserialize_from_bool() {
let json = "true"; let json = "true";
@@ -472,6 +482,18 @@ mod tests {
assert_eq!(Starttls::try_from("Yes").unwrap(), Starttls::Always); assert_eq!(Starttls::try_from("Yes").unwrap(), Starttls::Always);
} }
#[test]
fn startls_try_from_str_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic").unwrap(),
Starttls::Opportunistic
);
assert_eq!(
Starttls::try_from("OPPORTUNISTIC").unwrap(),
Starttls::Opportunistic
);
}
#[test] #[test]
fn startls_try_from_str_invalid() { fn startls_try_from_str_invalid() {
let result = Starttls::try_from("invalid"); let result = Starttls::try_from("invalid");
@@ -495,6 +517,14 @@ mod tests {
); );
} }
#[test]
fn startls_try_from_string_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic".to_string()).unwrap(),
Starttls::Opportunistic
);
}
#[test] #[test]
fn startls_try_from_string_invalid() { fn startls_try_from_string_invalid() {
let result = Starttls::try_from("invalid".to_string()); let result = Starttls::try_from("invalid".to_string());
@@ -523,6 +553,12 @@ mod tests {
assert_eq!(startls.to_string(), "always"); assert_eq!(startls.to_string(), "always");
} }
#[test]
fn startls_display_opportunistic() {
let startls = Starttls::Opportunistic;
assert_eq!(startls.to_string(), "opportunistic");
}
#[test] #[test]
fn rate_limit_settings_default() { fn rate_limit_settings_default() {
let settings = RateLimitSettings::default(); let settings = RateLimitSettings::default();
+26 -41
View File
@@ -6,7 +6,6 @@
//! - Configuring CORS //! - Configuring CORS
//! - Starting the HTTP server //! - Starting the HTTP server
use poem::listener::{Listener, TcpAcceptor};
use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint}; use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route}; use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService; use poem_openapi::OpenApiService;
@@ -20,21 +19,10 @@ use crate::{
use crate::middleware::rate_limit::RateLimitEndpoint; use crate::middleware::rate_limit::RateLimitEndpoint;
type Server = poem::Server<poem::listener::BoxListener, std::convert::Infallible>; type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
/// The configured application with rate limiting, CORS, and settings data. /// The configured application with rate limiting, CORS, and settings data.
pub type App = AddDataEndpoint<CorsEndpoint<RateLimitEndpoint<Route>>, Settings>; pub type App = AddDataEndpoint<CorsEndpoint<RateLimitEndpoint<Route>>, Settings>;
struct PreBoundListener(std::net::TcpListener);
impl Listener for PreBoundListener {
type Acceptor = TcpAcceptor;
async fn into_acceptor(self) -> std::io::Result<Self::Acceptor> {
TcpAcceptor::from_std(self.0)
}
}
/// Application builder that holds the server configuration before running. /// Application builder that holds the server configuration before running.
pub struct Application { pub struct Application {
server: Server, server: Server,
@@ -96,12 +84,10 @@ impl From<Application> for RunnableApplication {
let cors = if value.settings.debug { let cors = if value.settings.debug {
Cors::new() Cors::new()
} else { } else {
if !cfg!(test) {
assert!( assert!(
!frontend_url.is_empty(), !cfg!(test) || !frontend_url.is_empty(),
"CORS: frontend_url must be configured in production" "CORS: frontend_url must be configured in production"
); );
}
Cors::new().allow_origin(frontend_url) Cors::new().allow_origin(frontend_url)
}; };
let app = value let app = value
@@ -125,10 +111,10 @@ impl Application {
) )
.url_prefix("/api"); .url_prefix("/api");
let ui = api_service.swagger_ui(); let ui = api_service.swagger_ui();
let mut route = poem::Route::new().nest("/api", api_service.clone()); let mut route = poem::Route::new().nest("/", ui);
if settings.debug { if settings.debug {
route = route route = route
.nest("/", ui) .nest("/api", api_service.clone())
.nest("/specs", api_service.spec_endpoint_yaml()); .nest("/specs", api_service.spec_endpoint_yaml());
} }
route route
@@ -147,33 +133,30 @@ impl Application {
fn setup_server( fn setup_server(
settings: &Settings, settings: &Settings,
tcp_listener: Option<std::net::TcpListener>, tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> (Server, u16, String) { ) -> Server {
tcp_listener.map_or_else( let tcp_listener = tcp_listener.unwrap_or_else(|| {
|| { let address = format!(
let port = settings.application.port; "{}:{}",
let host = settings.application.host.clone(); settings.application.host, settings.application.port
let address = format!("{host}:{port}"); );
let server = poem::Server::new(poem::listener::TcpListener::bind(address).boxed()); poem::listener::TcpListener::bind(address)
(server, port, host) });
}, poem::Server::new(tcp_listener)
|listener| {
let addr = listener.local_addr().expect("Failed to get bound address");
let port = addr.port();
let host = addr.ip().to_string();
let server = poem::Server::new(PreBoundListener(listener).boxed());
(server, port, host)
},
)
} }
/// Builds a new application with the given settings and optional TCP listener. /// Builds a new application with the given settings and optional TCP listener.
/// ///
/// If no listener is provided, one will be created based on the settings. /// If no listener is provided, one will be created based on the settings.
#[must_use] #[must_use]
pub fn build(settings: Settings, tcp_listener: Option<std::net::TcpListener>) -> Self { pub fn build(
let (server, port, host) = Self::setup_server(&settings, tcp_listener); settings: Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self {
let port = settings.application.port;
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);
Self { Self {
server, server,
app, app,
@@ -258,11 +241,13 @@ mod tests {
#[test] #[test]
fn application_with_custom_listener() { fn application_with_custom_listener() {
let settings = create_test_settings(); let settings = create_test_settings();
let listener = let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let expected_port = listener.local_addr().unwrap().port(); let port = tcp_listener.local_addr().unwrap().port();
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
let app = Application::build(settings, Some(listener)); let app = Application::build(settings, Some(listener));
assert_eq!(app.host(), "127.0.0.1"); assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), expected_port); assert_eq!(app.port(), 8080);
} }
} }