diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cacabad..5107e30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,20 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.16.2] - 2026-05-XX + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed +- Allow HTTP to be used for configuring the server. + +## Fixed + ## [0.16.1] - 2026-04-25 -This version includes **multiple breaking changes**. If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added - OIDC: Extract username from JWT token. @@ -31,7 +42,6 @@ This version includes **multiple breaking changes**. If you are upgrading from v - Fix `CAA` record updates. - Check zone subdomains when finding zones - ## [0.16.0] - 2026-04-20 This version includes **multiple breaking changes**. If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index 7f3fa161..a2fca70d 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -14,7 +14,7 @@ pub mod token; pub const DEVICE_CODE_LEN: usize = 40; pub const USER_CODE_LEN: usize = 8; pub const RANDOM_CODE_LEN: usize = 32; -pub const CLIENT_ID_MAX_LEN: usize = 20; +pub const CLIENT_ID_MAX_LEN: usize = 60; pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 diff --git a/crates/http/src/api/mod.rs b/crates/http/src/api/mod.rs index 5fb712b5..d271d5d4 100644 --- a/crates/http/src/api/mod.rs +++ b/crates/http/src/api/mod.rs @@ -81,7 +81,7 @@ impl ManagementApi for Server { if let Some(email) = path.get(1).copied() { self.is_http_anonymous_request_allowed(session.remote_ip) .await?; - self.handle_discover_request(decode_path_element(email).as_ref()) + self.handle_discover_request(session, decode_path_element(email).as_ref()) .await } else { Err(trc::ResourceEvent::NotFound.into_err()) diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index 705f79d8..2b4b2346 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -49,6 +49,7 @@ pub struct OAuthMetadata { pub trait OAuthApiHandler: Sync + Send { fn handle_discover_request( &self, + session: &HttpSessionData, account_name: &str, ) -> impl Future> + Send; @@ -114,7 +115,11 @@ pub enum LoginResponse { } impl OAuthApiHandler for Server { - async fn handle_discover_request(&self, account_name: &str) -> trc::Result { + async fn handle_discover_request( + &self, + session: &HttpSessionData, + account_name: &str, + ) -> trc::Result { let account_name = account_name.trim().to_lowercase(); if let Some(domain_name) = account_name.try_domain_part() && let Some(endpoint) = self @@ -126,7 +131,7 @@ impl OAuthApiHandler for Server { .no_cache() .into_http_response()) } else { - self.handle_oidc_metadata().await + self.handle_oidc_metadata(!session.is_tls).await } } @@ -155,13 +160,13 @@ impl OAuthApiHandler for Server { if client_id.len() > CLIENT_ID_MAX_LEN { return Err(trc::AuthEvent::Error .into_err() - .details("Client ID is invalid.")); + .details("Client ID is too long.")); } else if redirect_uri .as_ref() .is_some_and(|uri| uri.starts_with("http://")) { #[cfg(not(feature = "dev_mode"))] - if !self.registry().is_recovery_mode() { + if !self.registry().is_recovery_mode() && code_challenge.is_none() { return Err(trc::AuthEvent::Error .into_err() .details("Redirect URI must be HTTPS.")); diff --git a/crates/http/src/auth/oauth/openid.rs b/crates/http/src/auth/oauth/openid.rs index 0dad6ca0..3d3f7f4a 100644 --- a/crates/http/src/auth/oauth/openid.rs +++ b/crates/http/src/auth/oauth/openid.rs @@ -33,7 +33,10 @@ pub trait OpenIdHandler: Sync + Send { account_id: u32, ) -> impl Future> + Send; - fn handle_oidc_metadata(&self) -> impl Future> + Send; + fn handle_oidc_metadata( + &self, + strip_base_url: bool, + ) -> impl Future> + Send; } impl OpenIdHandler for Server { @@ -52,8 +55,12 @@ impl OpenIdHandler for Server { .into_http_response()) } - async fn handle_oidc_metadata(&self) -> trc::Result { - let base_url = &self.core.network.http.url_https; + async fn handle_oidc_metadata(&self, strip_base_url: bool) -> trc::Result { + let base_url = if strip_base_url { + "" + } else { + &self.core.network.http.url_https + }; Ok(JsonResponse::new(OpenIdMetadata { authorization_endpoint: format!("{base_url}/login",), diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index e6b518aa..6de1f5c7 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -278,7 +278,7 @@ impl ParseHttp for Server { self.is_http_anonymous_request_allowed(session.remote_ip) .await?; - return self.handle_oidc_metadata().await; + return self.handle_oidc_metadata(false).await; } ("acme-challenge", &Method::GET) if self.has_acme_http_providers() => { if let Some(token) = path.next() { diff --git a/crates/jmap/src/registry/mapping/bootstrap.rs b/crates/jmap/src/registry/mapping/bootstrap.rs index 468b5e97..3ce2be68 100644 --- a/crates/jmap/src/registry/mapping/bootstrap.rs +++ b/crates/jmap/src/registry/mapping/bootstrap.rs @@ -38,6 +38,7 @@ use store::{ write::{AnyKey, BatchBuilder}, }; use types::id::Id; +use utils::is_valid_domain; pub(crate) async fn bootstrap_get( mut get: RegistryGetResponse<'_>, @@ -505,15 +506,6 @@ pub(crate) async fn bootstrap_set( Ok(set) } -fn is_valid_domain(hostname: &str) -> bool { - const RESERVED_TLDS: &[&str] = &["test", "localhost", "local", "internal"]; - psl::domain_str(hostname).is_some() - || RESERVED_TLDS.contains(&hostname) - || hostname - .rsplit_once('.') - .is_some_and(|(_, tld)| RESERVED_TLDS.contains(&tld)) -} - async fn write_object(registry: &RegistryStore, object: &Object) -> Result> { match registry.write(RegistryWrite::insert(object)).await { Ok(RegistryWriteResult::Success(id)) => Ok(id), diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 69f6e20d..838aa0b6 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -340,12 +340,18 @@ pub fn sanitize_domain(domain: &str) -> Option { } } - if found_dot - && last_ch != '.' - && psl::domain(result.as_bytes()).is_some_and(|d| d.suffix().typ().is_some()) - { + if found_dot && last_ch != '.' && is_valid_domain(&result) { Some(result) } else { None } } + +pub fn is_valid_domain(domain: &str) -> bool { + const RESERVED_TLDS: &[&str] = &["test", "localhost", "local", "internal"]; + psl::domain(domain.as_bytes()).is_some_and(|d| d.suffix().typ().is_some()) + || RESERVED_TLDS.contains(&domain) + || domain + .rsplit_once('.') + .is_some_and(|(_, tld)| RESERVED_TLDS.contains(&tld)) +}