Import upstream v0.16.24, stripped
Upstream commit: af37a234981722493b74623a983581691d2b70b6 Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 118 in 50 files Dangling module declarations removed: 5 Edits turning enterprise off: 25 Third-party code: 14 files, 0 not in THIRD-PARTY.md Renamed identifiers: 62 in 18 files Verification: clean The same Enterprise footprint as v0.16.23. The build check fails only on tests/src/directory/issuer.rs, unchanged since v0.16.23: it calls a helper from upstream's Enterprise-only OIDC test, and tests issuer-based directory routing, an Enterprise feature. main has never carried it.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -512,12 +512,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn index_is_rewritten_with_the_prefix_and_client_id() {
|
||||
let meta = oauth_client_id_meta("stalwart-webui");
|
||||
let meta = oauth_client_id_meta("inbuxa-webui");
|
||||
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
|
||||
|
||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"),
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"inbuxa-webui\" />"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("<title>Portal</title>"), "{html}");
|
||||
@@ -539,7 +539,7 @@ mod tests {
|
||||
#[test]
|
||||
fn index_without_a_placeholder_is_left_alone() {
|
||||
let bundle = "<head>\n <base href=\"/\" />\n</head>";
|
||||
let meta = oauth_client_id_meta("stalwart-webui");
|
||||
let meta = oauth_client_id_meta("inbuxa-webui");
|
||||
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
|
||||
|
||||
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
|
||||
|
||||
@@ -20,8 +20,8 @@ pub mod console;
|
||||
pub mod defaults;
|
||||
pub mod restore;
|
||||
|
||||
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes();
|
||||
pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
|
||||
pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
|
||||
pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
|
||||
|
||||
pub async fn fetch_resource(
|
||||
url: &str,
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::{Server, manager::application::Resource};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||
use std::fmt::Write;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
impl Server {
|
||||
pub async fn handle_autodiscover_request(
|
||||
@@ -24,81 +25,93 @@ impl Server {
|
||||
.details("Failed to parse autodiscover request")
|
||||
.ctx(trc::Key::Reason, err)
|
||||
})?;
|
||||
let default_host = &self.core.network.server_name;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t<User>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
|
||||
);
|
||||
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</User>");
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||
ServiceProtocol::Smtp => ("SMTP", [587, 465]),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for (is_tls, port) in ports.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t\t<SSL>{}</SSL>",
|
||||
if is_tls == 1 { "on" } else { "off" }
|
||||
);
|
||||
if is_tls == 1 {
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
|
||||
}
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
|
||||
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(&mut config, "\t\t</Account>");
|
||||
let _ = writeln!(&mut config, "\t</Response>");
|
||||
let _ = writeln!(&mut config, "</Autodiscover>");
|
||||
|
||||
Ok(Resource::new(
|
||||
"application/xml; charset=utf-8",
|
||||
config.into_bytes(),
|
||||
build_autodiscover_response(
|
||||
&emailaddress,
|
||||
&self.core.network.server_name,
|
||||
&self.core.network.info.services,
|
||||
)
|
||||
.into_bytes(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_autodiscover_response(
|
||||
emailaddress: &str,
|
||||
default_host: &str,
|
||||
services: &VecMap<ServiceProtocol, Service>,
|
||||
) -> String {
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t<User>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
|
||||
);
|
||||
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</User>");
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
for (protocol, service) in services {
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [(993, true), (143, false)]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [(995, true), (110, false)]),
|
||||
ServiceProtocol::Smtp => ("SMTP", [(465, true), (587, false)]),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Implicit TLS is listed first so that it is preferred (RFC 8314)
|
||||
for (port, is_tls) in ports {
|
||||
if is_tls || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
|
||||
let (ssl, encryption) = if is_tls {
|
||||
("on", "SSL")
|
||||
} else {
|
||||
("off", "TLS")
|
||||
};
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SSL>{ssl}</SSL>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>{encryption}</Encryption>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
|
||||
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(&mut config, "\t\t</Account>");
|
||||
let _ = writeln!(&mut config, "\t</Response>");
|
||||
let _ = writeln!(&mut config, "</Autodiscover>");
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
||||
if bytes.is_empty() {
|
||||
return Err("Empty request body".to_string());
|
||||
@@ -201,4 +214,78 @@ mod tests {
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autodiscover_encryption() {
|
||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
fn tag<'x>(block: &'x str, name: &str) -> &'x str {
|
||||
block
|
||||
.split_once(&format!("<{name}>"))
|
||||
.and_then(|(_, rest)| rest.split_once(&format!("</{name}>")))
|
||||
.map(|(value, _)| value)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
for (cleartext, expected) in [
|
||||
(
|
||||
false,
|
||||
vec![
|
||||
("IMAP", "993", "on", "SSL"),
|
||||
("POP3", "995", "on", "SSL"),
|
||||
("SMTP", "465", "on", "SSL"),
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
vec![
|
||||
("IMAP", "993", "on", "SSL"),
|
||||
("IMAP", "143", "off", "TLS"),
|
||||
("POP3", "995", "on", "SSL"),
|
||||
("POP3", "110", "off", "TLS"),
|
||||
("SMTP", "465", "on", "SSL"),
|
||||
("SMTP", "587", "off", "TLS"),
|
||||
],
|
||||
),
|
||||
] {
|
||||
let services: VecMap<ServiceProtocol, Service> = [
|
||||
ServiceProtocol::Imap,
|
||||
ServiceProtocol::Pop3,
|
||||
ServiceProtocol::Smtp,
|
||||
ServiceProtocol::Jmap,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|protocol| {
|
||||
(
|
||||
protocol,
|
||||
Service {
|
||||
hostname: None,
|
||||
cleartext,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let response = super::build_autodiscover_response(
|
||||
"[email protected]",
|
||||
"mail.example.com",
|
||||
&services,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.split("<Protocol>")
|
||||
.skip(1)
|
||||
.map(|block| (
|
||||
tag(block, "Type"),
|
||||
tag(block, "Port"),
|
||||
tag(block, "SSL"),
|
||||
tag(block, "Encryption"),
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"cleartext: {cleartext}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,6 +961,20 @@ impl DnsUpdater {
|
||||
)
|
||||
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
|
||||
}),
|
||||
DnsServer::PowerDns(server) => Ok(DnsUpdater {
|
||||
polling_interval: server.polling_interval.into_inner(),
|
||||
propagation_timeout: server.propagation_timeout.into_inner(),
|
||||
propagation_delay: server.propagation_delay.map(|d| d.into_inner()),
|
||||
ttl: server.ttl.into_inner(),
|
||||
core,
|
||||
updater: dns_update::DnsUpdater::new_pdns(
|
||||
server.api_key.secret().await?,
|
||||
server.endpoint,
|
||||
server.server_id,
|
||||
server.timeout.into_inner().into(),
|
||||
)
|
||||
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
|
||||
}),
|
||||
DnsServer::Safedns(server) => Ok(DnsUpdater {
|
||||
polling_interval: server.polling_interval.into_inner(),
|
||||
propagation_timeout: server.propagation_timeout.into_inner(),
|
||||
|
||||
@@ -4,33 +4,79 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use ahash::AHashMap;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p256::{
|
||||
SecretKey,
|
||||
ecdsa::{Signature, SigningKey, signature::Signer},
|
||||
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::{Url, header::HeaderValue};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60;
|
||||
const VAPID_TOKEN_REFRESH: u64 = VAPID_TOKEN_TTL / 2;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Vapid {
|
||||
key: VapidKey,
|
||||
contact: Option<String>,
|
||||
tokens: Arc<Mutex<AHashMap<String, VapidToken>>>,
|
||||
}
|
||||
|
||||
struct VapidToken {
|
||||
authorization: HeaderValue,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
impl Vapid {
|
||||
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
|
||||
Self { key, contact }
|
||||
Self {
|
||||
key,
|
||||
contact,
|
||||
tokens: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> &str {
|
||||
self.key.public_key()
|
||||
}
|
||||
|
||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> {
|
||||
self.key
|
||||
.authorization(endpoint, self.contact.as_deref(), now)
|
||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<HeaderValue> {
|
||||
let prefix = endpoint_prefix(endpoint)?;
|
||||
if let Some(token) = self
|
||||
.tokens
|
||||
.lock()
|
||||
.get(prefix)
|
||||
.filter(|token| token.is_fresh(now))
|
||||
{
|
||||
return Some(token.authorization.clone());
|
||||
}
|
||||
|
||||
let authorization = HeaderValue::try_from(self.key.authorization(
|
||||
endpoint,
|
||||
self.contact.as_deref(),
|
||||
now,
|
||||
)?)
|
||||
.ok()?;
|
||||
let mut tokens = self.tokens.lock();
|
||||
tokens.retain(|_, token| token.is_fresh(now));
|
||||
tokens.insert(
|
||||
prefix.to_string(),
|
||||
VapidToken {
|
||||
authorization: authorization.clone(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
Some(authorization)
|
||||
}
|
||||
}
|
||||
|
||||
impl VapidToken {
|
||||
fn is_fresh(&self, now: u64) -> bool {
|
||||
now.checked_sub(self.issued_at)
|
||||
.is_some_and(|age| age < VAPID_TOKEN_REFRESH)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,41 +149,15 @@ impl VapidKey {
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_origin(url: &str) -> Option<String> {
|
||||
fn endpoint_prefix(url: &str) -> Option<&str> {
|
||||
let (scheme, rest) = url.split_once("://")?;
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, host)| host)
|
||||
.unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
url.get(..scheme.len() + "://".len() + authority.len())
|
||||
}
|
||||
|
||||
let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
|
||||
let (addr, tail) = rest.split_once(']')?;
|
||||
(
|
||||
format!("[{}]", addr.to_ascii_lowercase()),
|
||||
tail.strip_prefix(':').filter(|port| !port.is_empty()),
|
||||
)
|
||||
} else if let Some((host, port)) = authority.rsplit_once(':') {
|
||||
(
|
||||
host.to_ascii_lowercase(),
|
||||
Some(port).filter(|p| !p.is_empty()),
|
||||
)
|
||||
} else {
|
||||
(authority.to_ascii_lowercase(), None)
|
||||
};
|
||||
|
||||
match port {
|
||||
Some(port)
|
||||
if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) =>
|
||||
{
|
||||
Some(format!("{scheme}://{host}:{port}"))
|
||||
}
|
||||
_ => Some(format!("{scheme}://{host}")),
|
||||
}
|
||||
fn endpoint_origin(url: &str) -> Option<String> {
|
||||
let origin = Url::parse(url).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
|
||||
pub fn normalize_contact(contact: &str) -> Option<String> {
|
||||
@@ -204,7 +224,12 @@ mod tests {
|
||||
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
|
||||
"http://[2001:db8::1]"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("https://attacker.example\\@fcm.googleapis.com/fcm/send/x").unwrap(),
|
||||
"https://attacker.example"
|
||||
);
|
||||
assert!(endpoint_origin("not-a-url").is_none());
|
||||
assert!(endpoint_origin("mailto:[email protected]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -334,6 +359,47 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_is_reused_per_endpoint_prefix() {
|
||||
let vapid = Vapid::new(test_key(), None);
|
||||
let now = 1_700_000_000;
|
||||
let token = vapid
|
||||
.authorization("https://push.example.com/push/a", now)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
vapid
|
||||
.authorization("https://push.example.com/push/b?x=1", now + 60)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
assert_ne!(
|
||||
vapid
|
||||
.authorization("https://other.example.com/push/a", now)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
assert_ne!(
|
||||
vapid
|
||||
.authorization("https://push.example.com/push/a", now - 1)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
let refreshed = vapid
|
||||
.authorization("https://push.example.com/push/a", now + VAPID_TOKEN_REFRESH)
|
||||
.unwrap();
|
||||
assert_ne!(refreshed, token);
|
||||
assert_eq!(
|
||||
vapid
|
||||
.authorization(
|
||||
"https://push.example.com/push/c",
|
||||
now + VAPID_TOKEN_REFRESH + 1
|
||||
)
|
||||
.unwrap(),
|
||||
refreshed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_omits_subject_when_no_contact() {
|
||||
let key = test_key();
|
||||
|
||||
Reference in New Issue
Block a user