5 Commits

Author SHA1 Message Date
namailu
8c69f9fecb docs: describe both fork changes and record public source availability
Some checks failed
trivy / Check (push) Has been cancelled
2026-08-18 12:21:47 +02:00
namailu
e446052e06 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.
2026-08-18 00:39:01 +02:00
root
dc9c52ae1d docs: point fork metadata at canonical repository
Some checks failed
trivy / Check (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
2026-07-29 15:20:13 +02:00
root
b4c5c4c25c docs: record pending public repository creation 2026-07-29 02:35:02 +02:00
root
831f3dc677 feat(auth): verify Basic credentials through OIDC provider 2026-07-29 02:31:01 +02:00
7 changed files with 344 additions and 17 deletions

26
Dockerfile.namailu Normal file
View File

@@ -0,0 +1,26 @@
# Namailu AGPL build of Stalwart 0.16.14.
#
# Only the PostgreSQL storage backend used by the deployment is enabled. Mail
# protocols, JMAP, OIDC directories and filtering are regular core features.
FROM rust:1.97.1-slim-trixie AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential clang cmake libclang-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --locked --release -p stalwart \
--no-default-features --features "postgres s3" \
&& cp /src/target/release/stalwart /tmp/stalwart
FROM stalwartlabs/stalwart:v0.16.14
LABEL org.opencontainers.image.source="https://git.facilitygo.com/filip/Stalwart" \
org.opencontainers.image.licenses="AGPL-3.0-only" \
org.opencontainers.image.description="Stalwart 0.16.14 with Namailu unified HUMAN password verification"
COPY --from=builder --chmod=0755 /tmp/stalwart /usr/local/bin/stalwart

57
NAMAILU_FORK.md Normal file
View File

@@ -0,0 +1,57 @@
# Namailu fork of Stalwart 0.16.14
Two changes on top of upstream `v0.16.14`; everything else is untouched.
| area | change |
|---|---|
| `crates/directory/src/backend/oidc/*`, `crates/http/src/auth/authenticate.rs` | unified HUMAN password: Basic credentials are verified against the identity provider (below) |
| `crates/http/src/request.rs` | the JMAP session document builds its URLs from the requested host (below) |
## Unified HUMAN password
This fork keeps the regular Stalwart OIDC Bearer flow and AppPassword flow intact,
and adds an intentionally narrow Basic-auth bridge for HUMAN accounts.
When the OIDC discovery document advertises
`password_verification_endpoint`, IMAP/SMTP/POP3 Basic credentials are POSTed
over HTTPS to that endpoint. The request uses the service bearer token from
`STALWART_OIDC_BASIC_AUTH_TOKEN`. A `204 No Content` response authenticates the
account; failure responses disclose no account data.
Security properties:
- the endpoint must be HTTPS, contain no userinfo, and use the issuer hostname;
- redirects and environment HTTP proxies are disabled for the OIDC client;
- password hashes, tokens, profiles, and sessions are never returned;
- the existing OIDC Bearer and scoped AppPassword paths are unchanged;
- the deployment additionally restricts the endpoint with an internal TLS
listener, source ACL, service bearer, rate limits, and bounded Argon2 work.
## JMAP session follows the requested host
Upstream builds the absolute URLs in the JMAP session document (`apiUrl`,
`uploadUrl`, `downloadUrl`, `eventSourceUrl`, websocket) from one configured
public URL. A deployment that serves several brands on one server therefore
tells a client that discovered the server on one hostname to continue on
another one.
With `STALWART_PUBLIC_URL_HOSTS=example.org,example.net` the session document
follows the host the request came in on. The host header is untrusted input, so
it is used only when it matches that allowlist exactly; anything else — including
a spoofed `Host` — falls back to `STALWART_PUBLIC_URL`, which keeps serving the
OAuth metadata, its issuer and the web admin links. Those must stay on one
stable host, so they are deliberately left alone. Unset or empty keeps the
upstream behaviour.
The provided image enables the PostgreSQL metadata and S3-compatible blob
backends used by Namailu. Build it with:
```sh
docker build -f Dockerfile.namailu \
-t namailu/stalwart:v0.16.14-unified-password .
```
The fork remains licensed under the upstream AGPL-3.0-only option. The complete
corresponding source of the running modified version is published at
<https://git.facilitygo.com/filip/Stalwart>, branch `namailu-unified-password`,
which is publicly readable.

View File

@@ -10,7 +10,7 @@ use crate::backend::oidc::{
DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
};
use registry::schema::structs;
use reqwest::Client;
use reqwest::{Client, redirect::Policy};
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use trc::AuthEvent;
@@ -34,7 +34,11 @@ impl OpenIdDirectory {
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = Client::builder()
.user_agent("Stalwart/1.0")
.timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(10))
// Credentials must never be redirected or inherited by a proxy from the
// process environment. The endpoint is a fixed, same-host HTTPS target.
.redirect(Policy::none())
.no_proxy()
.build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
let discovery_url = format!(
@@ -63,6 +67,25 @@ impl OpenIdDirectory {
)));
}
if let Some(endpoint) = &discovery.password_verification_endpoint {
let endpoint_url = reqwest::Url::parse(endpoint).map_err(|err| {
OidcError::Provider(format!("Invalid password_verification_endpoint URL: {err}"))
})?;
let issuer_url = reqwest::Url::parse(&discovery.issuer)
.map_err(|err| OidcError::Provider(format!("Invalid issuer URL: {err}")))?;
if endpoint_url.scheme() != "https"
|| endpoint_url.host_str() != issuer_url.host_str()
|| endpoint_url.username() != ""
|| endpoint_url.password().is_some()
{
return Err(OidcError::Provider(
"password_verification_endpoint must be HTTPS, contain no userinfo, \
and use the same hostname as the issuer"
.to_string(),
));
}
}
if let Some(supported) = &discovery.scopes_supported {
for scope in &config.require_scopes {
if !supported.contains(scope) {
@@ -140,6 +163,11 @@ impl OpenIdDirectory {
config,
http,
cache,
// Deployment-specific service identity. It is intentionally not part of
// the public registry object or discovery document and is never logged.
basic_auth_token: std::env::var("STALWART_OIDC_BASIC_AUTH_TOKEN")
.ok()
.filter(|token| !token.is_empty()),
})
}
}

View File

@@ -35,9 +35,89 @@ impl OpenIdDirectory {
}
err => AuthEvent::Error.into_err().reason(err),
}),
_ => Err(AuthEvent::Error
.into_err()
.reason("Unsupported credentials type for OIDC backend")),
Credentials::Basic {
username, secret, ..
} => self
.authenticate_basic(username, secret)
.await
.map_err(|err| match err {
OidcError::AuthorizationFailed(reason) => {
AuthEvent::Failed.into_err().reason(reason)
}
err => AuthEvent::Error.into_err().reason(err),
}),
}
}
/// Verify a legacy mail-protocol password against the OIDC provider's private
/// password oracle. The provider remains the only password/hash authority.
///
/// This is deliberately narrower than OAuth's removed password grant: no token,
/// profile, hash or session is returned. App passwords are still handled earlier
/// in `Server::route_auth_request` and never reach this endpoint.
async fn authenticate_basic(&self, username: &str, secret: &str) -> Result<Account, OidcError> {
let endpoint = self
.discovery
.document
.password_verification_endpoint
.as_deref()
.ok_or_else(|| OidcError::AuthorizationFailed("Invalid credentials".to_string()))?;
let token = self.basic_auth_token.as_deref().ok_or_else(|| {
OidcError::Provider(
"OIDC password verification endpoint is advertised but \
STALWART_OIDC_BASIC_AUTH_TOKEN is not configured"
.to_string(),
)
})?;
let username = username.trim().to_ascii_lowercase();
if username.is_empty() || username.len() > 320 || secret.is_empty() || secret.len() > 1024 {
return Err(OidcError::AuthorizationFailed(
"Invalid credentials".to_string(),
));
}
let response = self
.http
.post(endpoint)
.bearer_auth(token)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(serde_json::to_vec(&serde_json::json!({
"username": username,
"password": secret,
}))
.map_err(|err| {
OidcError::Provider(format!(
"Password verification request serialization failed: {err}"
))
})?)
.send()
.await
.map_err(|err| {
OidcError::Network(format!("Password verification request failed: {err}"))
})?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
Ok(Account {
email: username,
email_aliases: Vec::new(),
secret: None,
groups: None,
description: None,
})
} else if matches!(
response.status(),
reqwest::StatusCode::UNAUTHORIZED
| reqwest::StatusCode::FORBIDDEN
| reqwest::StatusCode::TOO_MANY_REQUESTS
) {
Err(OidcError::AuthorizationFailed(
"Invalid credentials".to_string(),
))
} else {
Err(OidcError::Provider(format!(
"Password verification endpoint returned HTTP {}",
response.status()
)))
}
}

View File

@@ -36,6 +36,11 @@ pub struct DiscoveryDocument {
pub userinfo_endpoint: String,
pub token_endpoint: String,
pub authorization_endpoint: String,
/// Optional private extension used by deployments that deliberately expose the
/// same primary password to legacy mail protocols. The endpoint returns no profile
/// or hash; it only verifies a Basic username/password over HTTPS.
#[serde(skip_serializing_if = "Option::is_none")]
pub password_verification_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_session_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -61,6 +66,7 @@ pub struct OpenIdDirectory {
pub discovery: OidcDiscovery,
http: Client,
cache: RwLock<JwksCache>,
basic_auth_token: Option<String>,
}
#[derive(Debug)]

View File

@@ -9,9 +9,10 @@ use common::{HttpAuthCache, Server, auth::AuthRequest, network::limiter::InFligh
use directory::Credentials;
use http_proto::{HttpRequest, HttpSessionData};
use hyper::header;
use base64::Engine;
use mail_parser::decoders::base64::base64_decode;
use std::future::Future;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub trait Authenticator: Sync + Send {
fn authenticate_headers(
@@ -91,14 +92,18 @@ impl Authenticator for Server {
.await?;
// Cache credentials
let max_cache_ttl = Duration::from_secs(self.core.oauth.oauth_expiry_token);
self.inner.cache.http_auth.insert(
token.into(),
HttpAuthCache {
account_id: access_token.account_id(),
revision: access_token.revision(),
credential_id: access_token.credential_id(),
// A verified JWT must never outlive its own exp just because
// the HTTP credential cache is configured for longer. The
// unverified peek only shortens an already authenticated token.
expires: Instant::now()
+ Duration::from_secs(self.core.oauth.oauth_expiry_token),
+ auth_cache_ttl(mechanism, token, max_cache_ttl),
},
);
@@ -119,6 +124,32 @@ impl Authenticator for Server {
}
}
fn auth_cache_ttl(mechanism: &str, token: &str, max_ttl: Duration) -> Duration {
if !mechanism.eq_ignore_ascii_case("bearer") {
return max_ttl;
}
let Some(payload) = token.split('.').nth(1).and_then(|payload| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()
}) else {
// Opaque OAuth tokens and Stalwart API keys keep the configured cache TTL.
return max_ttl;
};
let Some(exp) = serde_json::from_slice::<serde_json::Value>(&payload)
.ok()
.and_then(|claims| claims.get("exp").and_then(|exp| exp.as_u64()))
else {
return max_ttl;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Duration::from_secs(exp.saturating_sub(now).min(max_ttl.as_secs()))
}
pub trait HttpHeaders {
fn authorization(&self) -> Option<(&str, &str)>;
fn authorization_basic(&self) -> Option<&str>;
@@ -156,3 +187,30 @@ fn decode_plain_auth(token: &str) -> Option<Credentials> {
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bearer_cache_never_outlives_jwt_exp() {
let exp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 60;
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!(r#"{{"exp":{exp}}}"#));
let token = format!("header.{payload}.signature");
let ttl = auth_cache_ttl("Bearer", &token, Duration::from_secs(900));
assert!(ttl <= Duration::from_secs(60));
assert!(ttl >= Duration::from_secs(58));
}
#[test]
fn opaque_bearer_and_basic_keep_configured_cache_ttl() {
let configured = Duration::from_secs(900);
assert_eq!(auth_cache_ttl("Bearer", "API_opaque", configured), configured);
assert_eq!(auth_cache_ttl("Basic", "opaque", configured), configured);
}
}

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::sync::OnceLock;
use crate::{
HttpSessionManager,
api::{ManagementApi, ToManageHttpResponse},
@@ -210,23 +212,23 @@ impl ParseHttp for Server {
.await;
}
("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) {
// Authenticate request
let (_in_flight, access_token) =
self.authenticate_headers(&req, &session).await?;
self.handle_session_resource(
self.core.network.http.url_https.to_string(),
&access_token,
)
.await
.map(|s| s.into_http_response())
self.handle_session_resource(base_url, &access_token)
.await
.map(|s| s.into_http_response())
} else {
Ok(Session::new(
&self.core.network.http.url_https,
&self.core.jmap.capabilities,
Ok(
Session::new(&base_url, &self.core.jmap.capabilities)
.into_http_response(),
)
.into_http_response())
};
}
(_, &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()
}