v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,329 @@
|
||||
//! 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::{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<User>,
|
||||
}
|
||||
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<Option<Compiled>> {
|
||||
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"))?;
|
||||
ensure!(
|
||||
hash.salt.is_some_and(|s| s.as_str().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<HashMap<IpAddr, (Instant, u32)>>,
|
||||
}
|
||||
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<String>, 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<Semaphore>,
|
||||
limits: &Limits,
|
||||
) -> Result<Option<HeaderValue>, 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).ok().is_some_and(|hash| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &hash)
|
||||
.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<HeaderValue>) {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_ambiguous_headers_and_injection() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Basic dXNlcjpwYXNz"),
|
||||
);
|
||||
assert_eq!(credentials(&h).unwrap().0, "user");
|
||||
h.append(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Basic dXNlcjpwYXNz"),
|
||||
);
|
||||
assert!(credentials(&h).is_none());
|
||||
assert!(Settings {
|
||||
realm: "bad\r\nheader".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.compile()
|
||||
.is_err());
|
||||
assert!(Settings {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
.compile()
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn validates_and_strips_or_forwards_only_verified_credentials() {
|
||||
let hash = crate::auth::hash_password_offline("sufficiently-long-password".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut settings = Settings {
|
||||
enabled: true,
|
||||
users: vec![User {
|
||||
username: "alice".into(),
|
||||
password_hash: hash,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
for forward in [false, true] {
|
||||
settings.forward_credentials = forward;
|
||||
let auth = settings.compile().unwrap().unwrap();
|
||||
let mut h = HeaderMap::new();
|
||||
let raw = HeaderValue::from_str(&format!(
|
||||
"Basic {}",
|
||||
STANDARD.encode("alice:sufficiently-long-password")
|
||||
))
|
||||
.unwrap();
|
||||
h.insert("authorization", raw.clone());
|
||||
h.insert("remote-user", HeaderValue::from_static("spoof"));
|
||||
let verified = auth
|
||||
.authorize(
|
||||
&mut h,
|
||||
true,
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
Arc::new(Semaphore::new(2)),
|
||||
&Limits::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!h.contains_key("authorization"));
|
||||
auth.apply_upstream(&mut h, verified);
|
||||
assert_eq!(h.get("authorization"), forward.then_some(&raw));
|
||||
assert!(!h.contains_key("remote-user"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bounds_ip_limiter() {
|
||||
let limits = Limits::default();
|
||||
let ip = "127.0.0.1".parse().unwrap();
|
||||
for _ in 0..10 {
|
||||
limits.failed(ip);
|
||||
}
|
||||
assert!(limits.blocked(ip));
|
||||
assert!(!limits.blocked("127.0.0.2".parse().unwrap()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user