//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration. //! ACLs run first; credentials are never cached with content or written to logs. use anyhow::{ensure, Result}; use argon2::{ password_hash::{phc::PasswordHash, PasswordVerifier}, Argon2, }; use axum::response::{IntoResponse, Response}; use base64::{engine::general_purpose::STANDARD, Engine}; use http::{HeaderMap, HeaderValue, StatusCode}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, net::IpAddr, sync::{Arc, Mutex}, time::{Duration, Instant}, }; use tokio::sync::Semaphore; use zeroize::Zeroizing; fn realm() -> String { "Restricted".into() } fn yes() -> bool { true } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { pub enabled: bool, pub realm: String, pub require_tls: bool, pub forward_credentials: bool, pub users: Vec, } impl Default for Settings { fn default() -> Self { Self { enabled: false, realm: realm(), require_tls: yes(), forward_credentials: false, users: vec![], } } } #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct User { pub username: String, pub password_hash: String, } impl std::fmt::Debug for User { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("User") .field("username", &self.username) .finish_non_exhaustive() } } #[derive(Debug, Clone)] pub struct Compiled { settings: Settings, challenge: HeaderValue, } impl Settings { pub fn compile(&self) -> Result> { ensure!( !self.realm.is_empty() && self.realm.len() <= 128 && self .realm .bytes() .all(|c| c >= 32 && c < 127 && c != b'"' && c != b'\\'), "basic.invalid_realm" ); ensure!(self.users.len() <= 64, "basic.too_many_users"); let mut names = std::collections::HashSet::new(); for user in &self.users { validate_username(&user.username)?; ensure!(names.insert(&user.username), "basic.duplicate_username"); // Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters. ensure!( user.password_hash.len() <= 256 && user .password_hash .starts_with("$argon2id$v=19$m=19456,t=2,p=1$"), "basic.unsupported_hash" ); let hash = PasswordHash::new(&user.password_hash) .map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?; // Match the former 16-character minimum for the PHC-encoded salt. // phc 0.6 Salt::len() now measures decoded bytes instead. ensure!( hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16) && hash.hash.is_some_and(|h| h.len() == 32), "basic.invalid_hash" ); } if !self.enabled { return Ok(None); } ensure!(!self.users.is_empty(), "basic.users_required"); Ok(Some(Compiled { settings: self.clone(), challenge: format!("Basic realm=\"{}\", charset=\"UTF-8\"", self.realm).parse()?, })) } } pub fn validate_username(name: &str) -> Result<()> { ensure!( !name.is_empty() && name.len() <= 128 && !name.contains(':') && !name.chars().any(char::is_control), "basic.invalid_username" ); Ok(()) } #[derive(Default)] pub struct Limits { failures: Mutex>, } impl Limits { fn blocked(&self, ip: IpAddr) -> bool { let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60)); map.get(&ip).is_some_and(|(_, count)| *count >= 10) || (map.len() >= 4096 && !map.contains_key(&ip)) } fn failed(&self, ip: IpAddr) { let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60)); if map.len() < 4096 || map.contains_key(&ip) { let entry = map.entry(ip).or_insert((Instant::now(), 0)); entry.1 = entry.1.saturating_add(1); } } } fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response { let mut response = (status, "RustProxy: authentication required\n").into_response(); response.headers_mut().insert( "cache-control", HeaderValue::from_static("private, no-store"), ); if let Some(challenge) = challenge { response .headers_mut() .insert("www-authenticate", challenge.clone()); } if status == StatusCode::TOO_MANY_REQUESTS { response .headers_mut() .insert("retry-after", HeaderValue::from_static("60")); } // Custom error pages must not turn an authentication failure into success. response .extensions_mut() .insert(crate::tinyauth::AuthenticationFailure); response } fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing, HeaderValue)> { if headers.get_all("authorization").iter().count() != 1 { return None; } let value = headers.get("authorization")?; let raw = value.to_str().ok()?; if raw.len() > 1024 { return None; } let (scheme, token) = raw.split_once(' ')?; if !scheme.eq_ignore_ascii_case("basic") || token.is_empty() || token.contains(char::is_whitespace) { return None; } let bytes = Zeroizing::new(STANDARD.decode(token).ok()?); let decoded = std::str::from_utf8(&bytes).ok()?; let (name, password) = decoded.split_once(':')?; if validate_username(name).is_err() || password.len() > 128 || password.chars().any(char::is_control) { return None; } Some(( name.to_owned(), Zeroizing::new(password.to_owned()), value.clone(), )) } impl Compiled { pub async fn authorize( &self, headers: &mut HeaderMap, tls: bool, ip: IpAddr, workers: Arc, limits: &Limits, ) -> Result, Response> { let input = credentials(headers); headers.remove("authorization"); headers.remove("remote-user"); headers.remove("remote-email"); if self.settings.require_tls && !tls { return Err(denied(StatusCode::FORBIDDEN, None)); } if limits.blocked(ip) { return Err(denied(StatusCode::TOO_MANY_REQUESTS, None)); } let Some((name, password, original)) = input else { // A browser's first request normally has no Authorization; do not count that as a bad password. return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); }; let found = self.settings.users.iter().find(|u| u.username == name); let known = found.is_some(); let encoded = found .unwrap_or(&self.settings.users[0]) .password_hash .clone(); let permit = workers .try_acquire_owned() .map_err(|_| denied(StatusCode::SERVICE_UNAVAILABLE, None))?; let verified = tokio::task::spawn_blocking(move || { let _permit = permit; PasswordHash::new(&encoded).is_ok() && Argon2::default() .verify_password(password.as_bytes(), encoded.as_str()) .is_ok() }) .await .unwrap_or(false); if !known || !verified { limits.failed(ip); return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); } Ok(self.settings.forward_credentials.then_some(original)) } pub fn apply_upstream(&self, headers: &mut HeaderMap, verified: Option) { headers.remove("authorization"); headers.remove("remote-user"); headers.remove("remote-email"); if self.settings.forward_credentials { if let Some(value) = verified { headers.insert("authorization", value); } } } } #[cfg(test)] include!("basic_auth/tests.rs");