This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 12:57:19 +01:00
1 parent b6e8ddb9c7
commit 818cae8592
12 files changed
+253 -33

No files matched your search

+129 -27
View File
@@ -96,12 +96,71 @@ pub async fn handler(
.map(|(l, _)| l)
.unwrap_or_else(|| "-".into());
let size = hdr("content-length").parse::<u64>().unwrap_or(0);
let resp = inner(app.clone(), peer, method.clone(), headers, body).await;
let mut resp = resp;
// request headers for the trace (never credentials or cookies)
let hdr_dump: Vec<String> = headers
.iter()
.filter(|(k, _)| {
!matches!(
k.as_str(),
"authorization" | "cookie" | "proxy-authorization"
)
})
.map(|(k, v)| {
format!(
"{}: {}",
k.as_str(),
String::from_utf8_lossy(v.as_bytes())
.chars()
.filter(|c| !c.is_control())
.take(200)
.collect::<String>()
)
})
.collect();
let typed_user = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split_once(' '))
.and_then(|(_, d)| {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(d.trim())
.ok()
})
.and_then(|b| String::from_utf8(b).ok())
.and_then(|d| {
d.split_once(':')
.map(|(u, _)| u.chars().take(120).collect::<String>())
})
.unwrap_or_default();
let body_bytes = match http_body_util::Limited::new(body, 64 * 1024)
.collect()
.await
{
Ok(b) => b.to_bytes(),
Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
};
let resp = inner(
app.clone(),
peer,
method.clone(),
headers,
Body::from(body_bytes.clone()),
)
.await;
let (mut parts, rbody) = resp.into_parts();
let rbytes = rbody
.collect()
.await
.map(|b| b.to_bytes())
.unwrap_or_default();
parts.headers.remove("content-length");
let mut resp = Response::from_parts(parts, Body::from(rbytes.clone()));
resp.headers_mut()
.insert("X-ActivePush", HeaderValue::from_static("autodiscover"));
let code = resp.status().as_u16();
let status = match code {
200 if String::from_utf8_lossy(&rbytes).contains("<ErrorCode>") => "invalid request",
200 => "ok",
401 => "auth required",
429 => "locked out",
@@ -109,6 +168,18 @@ pub async fn handler(
_ => "error",
};
tracing::info!(target: "active_push::autodiscover", ip = %ip, host = %host, path = %uri.path(), method = %method, http = code, user = %user, ua = %ua, "autodiscover {status}");
let clip = |b: &[u8]| -> String {
String::from_utf8_lossy(&b[..b.len().min(3000)])
.chars()
.filter(|c| !c.is_control() || *c == '\n')
.collect()
};
app.ad_trace_push(serde_json::json!({
"ts": now(), "ip": ip, "host": host, "method": method.as_str(), "path": uri.path(),
"typed_user": typed_user, "http": code, "ms": started.elapsed().as_millis() as u64,
"request_headers": hdr_dump, "request_body": clip(&body_bytes),
"response_body": clip(&rbytes),
}));
app.metrics.record(Event {
ts: now(),
user,
@@ -199,36 +270,67 @@ async fn inner(
if !user_allowed(&login, &s) {
return unauthorized();
}
if let Some((l, p)) = &creds {
let key = app.pool.key(l, p);
if !app.auth.is_cached(&key) {
let mut sess = app.session(l, p);
match sess.authenticate().await {
Ok(AuthResult::Ok) => {
app.auth.success(&user_key);
app.auth.cache_ok(key, s.security.auth_cache_secs);
sess.close().await;
}
Ok(AuthResult::Denied) => {
app.metrics.auth_failure();
if s.security.log_auth_failures {
tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover");
// iOS sends the same password twice: once with "user@domain" and once with the bare "user",
// and treats a refusal of either as a failed Autodiscover. When the bare name is the local
// part of the requested address, try the full address first (the form most IMAP servers with
// several domains know), then the name as given.
let mut login = login;
if let Some((given, pass)) = &creds {
let mut candidates: Vec<String> = Vec::new();
if !given.contains('@') {
if let Some((local, _)) = email.rsplit_once('@') {
if local.eq_ignore_ascii_case(given) {
if let Some(full) = normalize_login(&email, &s) {
if user_allowed(&full, &s) {
candidates.push(full);
}
}
let (m, w, lo) = (
s.security.auth_fail_max,
s.security.auth_fail_window_secs,
s.security.lockout_secs,
);
app.auth.fail(&ip_key, m * 3, w, lo);
app.auth.fail(&user_key, m, w, lo);
return unauthorized();
}
Err(e) => {
tracing::error!("autodiscover: backend unavailable: {e:#}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
}
candidates.push(given.clone());
let mut accepted: Option<String> = candidates
.iter()
.find(|c| app.auth.is_cached(&app.pool.key(c, pass)))
.cloned();
if accepted.is_none() {
for cand in &candidates {
let mut sess = app.session(cand, pass);
match sess.authenticate().await {
Ok(AuthResult::Ok) => {
app.auth.success(&user_key);
app.auth
.cache_ok(app.pool.key(cand, pass), s.security.auth_cache_secs);
sess.close().await;
accepted = Some(cand.clone());
break;
}
Ok(AuthResult::Denied) => {}
Err(e) => {
tracing::error!("autodiscover: backend unavailable: {e:#}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
}
}
}
match accepted {
Some(l) => login = l,
None => {
app.metrics.auth_failure();
if s.security.log_auth_failures {
tracing::warn!(target: "active_push::auth", "authentication failed user={given} ip={ip} service=autodiscover");
}
let (m, w, lo) = (
s.security.auth_fail_max,
s.security.auth_fail_window_secs,
s.security.lockout_secs,
);
app.auth.fail(&ip_key, m * 3, w, lo);
app.auth.fail(&user_key, m, w, lo);
return unauthorized();
}
}
}
if email.is_empty() || !email.contains('@') {
email = crate::backend::primary_email(&s, &login);