JMAP session urls follow the requested host (STALWART_PUBLIC_URL_HOSTS)

The session document hands the client absolute urls built from the single
configured public url, so a client that discovered the server on one brand's
hostname was told to continue on another brand's hostname.

With STALWART_PUBLIC_URL_HOSTS=namailu.cz,mailows.com the session follows the
host the request came in on; anything not on that allowlist (including a spoofed
Host header) keeps the configured public url, which still serves OAuth metadata,
its issuer and the web admin links — those must stay on one stable host.
This commit is contained in:
namailu
2026-08-17 15:40:42 +00:00
parent dc9c52ae1d
commit e446052e06

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use std::sync::OnceLock;
use crate::{ use crate::{
HttpSessionManager, HttpSessionManager,
api::{ManagementApi, ToManageHttpResponse}, api::{ManagementApi, ToManageHttpResponse},
@@ -210,23 +212,23 @@ impl ParseHttp for Server {
.await; .await;
} }
("session", &Method::GET) => { ("session", &Method::GET) => {
// Urls in the session document follow the host the client
// used (see `session_base_url`), not the one configured
// public url — brands must not be mixed up.
let base_url = session_base_url(self, &req);
return if req.headers().contains_key(header::AUTHORIZATION) { return if req.headers().contains_key(header::AUTHORIZATION) {
// Authenticate request // Authenticate request
let (_in_flight, access_token) = let (_in_flight, access_token) =
self.authenticate_headers(&req, &session).await?; self.authenticate_headers(&req, &session).await?;
self.handle_session_resource( self.handle_session_resource(base_url, &access_token)
self.core.network.http.url_https.to_string(), .await
&access_token, .map(|s| s.into_http_response())
)
.await
.map(|s| s.into_http_response())
} else { } else {
Ok(Session::new( Ok(
&self.core.network.http.url_https, Session::new(&base_url, &self.core.jmap.capabilities)
&self.core.jmap.capabilities, .into_http_response(),
) )
.into_http_response())
}; };
} }
(_, &Method::OPTIONS) => { (_, &Method::OPTIONS) => {
@@ -864,3 +866,73 @@ impl SessionManager for HttpSessionManager {
} }
} }
} }
/// Public hosts (comma separated) that may be echoed back in a JMAP session
/// document, e.g. `STALWART_PUBLIC_URL_HOSTS=namailu.cz,mailows.com`.
///
/// The session resource hands the client ABSOLUTE urls (`apiUrl`, `uploadUrl`,
/// `downloadUrl`, `eventSourceUrl`, websocket). Upstream builds them from the
/// single configured public url, so a client that discovered the server on one
/// brand's hostname is told to continue on another brand's hostname. In a
/// multi-brand deployment that is both confusing and a hard dependency on a
/// hostname the customer never typed.
///
/// `STALWART_PUBLIC_URL` deliberately keeps serving everything else — OAuth
/// metadata, its issuer and the web admin links — because those must stay on
/// one stable host; only the session document follows the request.
///
/// Unset or empty keeps the upstream behaviour.
fn session_public_hosts() -> &'static [String] {
static HOSTS: OnceLock<Vec<String>> = OnceLock::new();
HOSTS
.get_or_init(|| {
std::env::var("STALWART_PUBLIC_URL_HOSTS")
.unwrap_or_default()
.split(',')
.map(|host| {
host.trim()
.trim_end_matches('.')
.to_ascii_lowercase()
})
.filter(|host| !host.is_empty())
.collect()
})
.as_slice()
}
/// Base url for the session document: the requested host when it is allowlisted,
/// otherwise the configured public url.
///
/// The host is taken from the request, so it is untrusted input — it is used only
/// when it matches the allowlist exactly. Without that check a spoofed `Host`
/// header would make us hand the client urls on an attacker's hostname.
fn session_base_url(server: &Server, req: &HttpRequest) -> String {
let allowed = session_public_hosts();
if !allowed.is_empty() {
if let Some(value) = req
.headers()
.get("x-forwarded-host")
.or_else(|| req.headers().get(header::HOST))
.and_then(|value| value.to_str().ok())
{
// A chain of proxies appends to X-Forwarded-Host; only the first hop
// is the host the client actually asked for.
let first = value.split(',').next().unwrap_or(value).trim();
// Strip the port, but keep IPv6 literals (`[::1]`) intact.
let host = match first.rsplit_once(':') {
Some((host, port))
if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) =>
{
host
}
_ => first,
}
.trim_end_matches('.')
.to_ascii_lowercase();
if allowed.iter().any(|allowed_host| *allowed_host == host) {
return format!("https://{host}");
}
}
}
server.core.network.http.url_https.clone()
}