sozu_lib/protocol/mux/auth.rs
1//! HTTP Basic authentication helpers shared by the H1 and H2 mux paths.
2//!
3//! The runtime stores credentials as `username:hex(sha256(password))`
4//! entries on each cluster's `authorized_hashes`. On every request that
5//! traverses a frontend with `required_auth = true`, the mux extracts the
6//! `Authorization: Basic <token>` header from the front kawa, decodes the
7//! base64 token, splits on the first `:` into `<user>:<password>`, hashes
8//! the password with SHA-256, and rebuilds the canonical
9//! `<user>:<hex(sha256)>` form. Comparison against the cluster's hash list
10//! uses [`subtle::ConstantTimeEq`] over a full pass, never short-circuiting,
11//! so the time spent validating a credential does not leak which slot
12//! matched (or whether any did at all).
13//!
14//! The extractor is intentionally permissive (`Option`-returning) — any
15//! malformed input is reported as "no credential", and the caller emits
16//! the standard 401 response. We never panic on hostile input.
17
18use base64::{Engine, engine::general_purpose::STANDARD};
19use kawa::{Block, Pair, Store};
20use sha2::{Digest, Sha256};
21use subtle::ConstantTimeEq;
22
23use super::GenericHttpStream;
24use crate::protocol::http::parser::compare_no_case;
25
26/// Built-in default for the maximum length, in bytes, of a base64-decoded
27/// `Authorization: Basic` payload. RFC 7617 does not impose a limit, but
28/// credentials longer than 4 KiB are pathological for HTTP Basic.
29/// Operators can override via `basic_auth_max_credential_bytes` in the
30/// main TOML config; the override is committed once at worker boot via
31/// [`set_max_decoded_credential_bytes`].
32const DEFAULT_MAX_DECODED_CREDENTIAL_BYTES: usize = 4096;
33
34/// Process-wide override for [`DEFAULT_MAX_DECODED_CREDENTIAL_BYTES`].
35/// Set once on each worker at startup from
36/// [`sozu_command::proto::command::ServerConfig::basic_auth_max_credential_bytes`]
37/// via [`set_max_decoded_credential_bytes`]. Reading uses a relaxed
38/// `OnceLock::get` so the auth fast path stays branch-and-load with no
39/// atomic hand-off. Set-once semantics are sufficient because the cap is
40/// a global hardening knob — it never changes after boot.
41static MAX_DECODED_CREDENTIAL_BYTES_OVERRIDE: std::sync::OnceLock<usize> =
42 std::sync::OnceLock::new();
43
44/// Install the operator-configured cap. Called from
45/// `lib::server::Server::try_new_from_config` exactly once per worker
46/// process. Subsequent calls are no-ops (the `OnceLock` rejects the
47/// second `set`); the first wins. A `0` value is treated as "use the
48/// built-in default" so an operator config that explicitly sets `0` does
49/// not disable Basic-auth length-bound protection by accident.
50pub fn set_max_decoded_credential_bytes(cap: usize) {
51 if cap == 0 {
52 return;
53 }
54 let _ = MAX_DECODED_CREDENTIAL_BYTES_OVERRIDE.set(cap);
55}
56
57/// Resolve the active cap: operator override when present, otherwise the
58/// built-in [`DEFAULT_MAX_DECODED_CREDENTIAL_BYTES`].
59fn max_decoded_credential_bytes() -> usize {
60 MAX_DECODED_CREDENTIAL_BYTES_OVERRIDE
61 .get()
62 .copied()
63 .unwrap_or(DEFAULT_MAX_DECODED_CREDENTIAL_BYTES)
64}
65
66/// Find the first `Authorization` header value in the front kawa.
67///
68/// Returns the raw bytes of the header value. Header names are matched
69/// case-insensitively per RFC 9110 §5.1. Returns `None` when no header is
70/// present, when the header has been elided (`Store::Empty`), or when the
71/// value's underlying bytes can't be resolved against the kawa buffer.
72pub fn extract_authorization_header(kawa: &GenericHttpStream) -> Option<Vec<u8>> {
73 let buf = kawa.storage.buffer();
74 for block in &kawa.blocks {
75 if let Block::Header(Pair { key, val }) = block {
76 if matches!(key, Store::Empty) {
77 continue;
78 }
79 let key_bytes = key.data(buf);
80 if compare_no_case(key_bytes, b"authorization") {
81 return Some(val.data(buf).to_vec());
82 }
83 }
84 }
85 None
86}
87
88/// Decode a `Basic <token>` value into the canonical
89/// `username:hex(sha256(password))` shape that
90/// [`check_authorized_hashes`] compares against. Returns `None` for any
91/// malformed input — wrong scheme, non-base64 token, missing `:`,
92/// non-UTF-8 username, or oversized payload.
93pub fn canonicalize_basic_credentials(value: &[u8]) -> Option<String> {
94 // Trim leading SP only. RFC 7235 §2.1 / RFC 9110 §11.4 define the
95 // header value's grammar as `auth-scheme [ 1*SP token68 ]`, so HTAB
96 // is not permitted between the value start and the scheme. Some
97 // clients still emit a leading SP run before the scheme; that's
98 // tolerated here because the OWS preceding the field-value is
99 // already stripped by the parser.
100 let mut rest = value;
101 while let Some((&b' ', tail)) = rest.split_first() {
102 rest = tail;
103 }
104
105 // RFC 7617 § 2 mandates ASCII-case-insensitive `Basic` scheme prefix.
106 let scheme_len = b"Basic".len();
107 if rest.len() < scheme_len || !compare_no_case(&rest[..scheme_len], b"basic") {
108 return None;
109 }
110 rest = &rest[scheme_len..];
111
112 // RFC 7235 §2.1: scheme and token68 are separated by `1*SP`.
113 // Reject HTAB and reject zero spaces (the scheme-token boundary must
114 // exist). Multiple leading spaces are tolerated for compatibility
115 // with clients that emit `Basic abc==`.
116 let mut saw_space = false;
117 while let Some((&b' ', tail)) = rest.split_first() {
118 rest = tail;
119 saw_space = true;
120 }
121 if !saw_space || rest.is_empty() {
122 return None;
123 }
124
125 // ── Pre-decode length cap ──
126 //
127 // base64 expands 3 bytes → 4 characters. The post-decode length check
128 // below would still allocate the full decoded payload before the
129 // rejection runs, so a peer can force a per-attempt allocation up to
130 // the request-buffer cap on every failed Basic-auth probe.
131 // `basic_auth_max_credential_bytes` is meant as a per-request memory
132 // bound; cap the *encoded* size first so `STANDARD.decode` never sees
133 // a payload bigger than the bound it implies. The `+ 4` slack covers
134 // up to two `=` padding characters plus rounding.
135 let max_decoded = max_decoded_credential_bytes();
136 let max_encoded = max_decoded.saturating_mul(4).saturating_add(2) / 3 + 4;
137 if rest.len() > max_encoded {
138 return None;
139 }
140
141 let decoded = STANDARD.decode(rest).ok()?;
142 if decoded.len() > max_decoded {
143 return None;
144 }
145
146 let colon = decoded.iter().position(|&b| b == b':')?;
147 let username = std::str::from_utf8(&decoded[..colon]).ok()?;
148 let password = &decoded[colon + 1..];
149
150 let mut hasher = Sha256::new();
151 hasher.update(password);
152 let digest = hasher.finalize();
153
154 Some(format!("{}:{}", username, hex::encode(digest)))
155}
156
157/// Maximum byte length of a canonical `username:hex(sha256)` credential
158/// we are willing to compare in constant time. The realistic shape uses a
159/// short username plus a 65-byte `:hex64` tail, so 256 covers every
160/// reasonable operator config while keeping the per-compare stack buffer
161/// small.
162const AUTH_COMPARE_PAD_LEN: usize = 256;
163
164/// Pad `input` into a fixed-length envelope so [`subtle::ConstantTimeEq`]
165/// runs its full byte-loop regardless of whether the candidate and the
166/// stored hash differ in length. The trailing 8 bytes encode the input's
167/// actual length as little-endian `u64` so two inputs that share a prefix
168/// but differ in total length cannot collide post-padding.
169fn pad_for_constant_time_compare(input: &[u8]) -> [u8; AUTH_COMPARE_PAD_LEN + 8] {
170 let mut buf = [0u8; AUTH_COMPARE_PAD_LEN + 8];
171 let n = input.len().min(AUTH_COMPARE_PAD_LEN);
172 buf[..n].copy_from_slice(&input[..n]);
173 buf[AUTH_COMPARE_PAD_LEN..].copy_from_slice(&(input.len() as u64).to_le_bytes());
174 buf
175}
176
177/// Compare `candidate` against every entry in `authorized_hashes` using
178/// constant-time equality. Returns `true` if any entry matches.
179///
180/// Both sides are padded to a fixed [`AUTH_COMPARE_PAD_LEN`] envelope plus
181/// a length suffix before [`subtle::ConstantTimeEq`] runs, so:
182/// * the per-entry compare loop iterates the full padded length even
183/// when the candidate and the stored hash differ in length (subtle's
184/// slice `ct_eq` short-circuits on length mismatch — the padding here
185/// defeats that leak);
186/// * the outer loop iterates the entire slice on every call regardless
187/// of where (or whether) a match is found, so the time spent
188/// validating a credential does not vary with the position of the
189/// matching entry.
190///
191/// This defeats timing side-channel attacks that would otherwise leak
192/// the size of the realm, the length of the matching username, or the
193/// index of the matching credential.
194///
195/// ── Length-bound prelude ──
196///
197/// `pad_for_constant_time_compare` silently truncates inputs longer than
198/// [`AUTH_COMPARE_PAD_LEN`]. Two credentials that share their first 256
199/// bytes — for example, the same long username with different password
200/// digests — would produce identical padded buffers (and identical length
201/// suffixes if the inputs share a length), letting a bogus password
202/// authenticate as the long-username slot. The pre-loop guard below
203/// rejects any input that would be truncated before the compare runs, so
204/// the constant-time loop only ever sees values we can encode without
205/// loss. Stored entries that exceed the bound are skipped by the same
206/// rule — operator config should never produce them, but we refuse to
207/// silently truncate one if it slips through.
208pub fn check_authorized_hashes(candidate: &str, authorized_hashes: &[String]) -> bool {
209 if candidate.len() > AUTH_COMPARE_PAD_LEN {
210 return false;
211 }
212 let candidate_padded = pad_for_constant_time_compare(candidate.as_bytes());
213 let mut matched = subtle::Choice::from(0u8);
214 for hash in authorized_hashes {
215 if hash.len() > AUTH_COMPARE_PAD_LEN {
216 continue;
217 }
218 let entry_padded = pad_for_constant_time_compare(hash.as_bytes());
219 // Both buffers are exactly `AUTH_COMPARE_PAD_LEN + 8` bytes, so
220 // subtle's slice `ct_eq` runs the full byte-loop instead of
221 // bailing on length. `BitOr` over `Choice` likewise does not
222 // branch.
223 matched |= candidate_padded.as_slice().ct_eq(entry_padded.as_slice());
224 }
225 bool::from(matched)
226}
227
228/// Convenience: pull `Authorization` from the kawa, canonicalise, and
229/// compare in constant time against the authorized list.
230pub fn check_basic(kawa: &GenericHttpStream, authorized_hashes: &[String]) -> bool {
231 if authorized_hashes.is_empty() {
232 return false;
233 }
234 let Some(header_value) = extract_authorization_header(kawa) else {
235 return false;
236 };
237 let Some(canonical) = canonicalize_basic_credentials(&header_value) else {
238 return false;
239 };
240 check_authorized_hashes(&canonical, authorized_hashes)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 /// SHA-256 of the literal byte string `"secret"`, lowercase hex.
248 /// Computed once and pinned here so the unit tests don't have to
249 /// re-derive it on every run.
250 const SECRET_SHA256_HEX: &str =
251 "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b";
252
253 #[test]
254 fn canonicalize_round_trips_admin_secret() {
255 // base64("admin:secret") = "YWRtaW46c2VjcmV0"
256 let canonical = canonicalize_basic_credentials(b"Basic YWRtaW46c2VjcmV0")
257 .expect("well-formed credential should canonicalize");
258 assert_eq!(canonical, format!("admin:{SECRET_SHA256_HEX}"));
259 }
260
261 #[test]
262 fn canonicalize_is_case_insensitive_on_scheme() {
263 let canonical = canonicalize_basic_credentials(b"basic YWRtaW46c2VjcmV0")
264 .expect("lowercase scheme should still canonicalize");
265 assert_eq!(canonical, format!("admin:{SECRET_SHA256_HEX}"));
266 }
267
268 #[test]
269 fn canonicalize_rejects_non_basic_scheme() {
270 assert!(canonicalize_basic_credentials(b"Bearer token").is_none());
271 }
272
273 #[test]
274 fn canonicalize_rejects_garbage_base64() {
275 assert!(canonicalize_basic_credentials(b"Basic !!not-base64!!").is_none());
276 }
277
278 #[test]
279 fn canonicalize_rejects_missing_colon() {
280 // base64("admin") = "YWRtaW4="
281 assert!(canonicalize_basic_credentials(b"Basic YWRtaW4=").is_none());
282 }
283
284 #[test]
285 fn canonicalize_rejects_oversized_payload() {
286 // 5 KB+ base64 payload — well above the active cap.
287 let payload = "a".repeat(max_decoded_credential_bytes() * 2);
288 let token = STANDARD.encode(format!("{payload}:pwd"));
289 let header = format!("Basic {token}");
290 assert!(canonicalize_basic_credentials(header.as_bytes()).is_none());
291 }
292
293 /// The post-decode length check used to let `STANDARD.decode` allocate
294 /// the full payload before rejection, so an attacker could force a
295 /// per-attempt allocation up to the request-buffer cap on every
296 /// failed Basic-auth probe. The pre-decode length cap rejects an
297 /// oversized encoded payload before any allocation.
298 ///
299 /// We don't measure the allocator directly here; instead we pass an
300 /// encoded payload that is *much* larger than `max_decoded * 4 / 3`
301 /// and assert `None`. Combined with the source code's pre-decode
302 /// guard, that's enough to pin the contract.
303 #[test]
304 fn canonicalize_rejects_oversized_encoded_payload_before_decode() {
305 // 16× the maximum encoded budget. Without the pre-decode cap,
306 // base64::STANDARD::decode would allocate ~12× max_decoded bytes
307 // before the post-decode rejection ran.
308 let oversize = "A".repeat(max_decoded_credential_bytes() * 16);
309 let header = format!("Basic {oversize}");
310 assert!(canonicalize_basic_credentials(header.as_bytes()).is_none());
311 }
312
313 /// Calling `set_max_decoded_credential_bytes(0)` is a no-op so an
314 /// operator config that explicitly zeroes the field cannot disable
315 /// the hardening cap by accident — the default 4096 stays in force.
316 #[test]
317 fn set_max_decoded_credential_bytes_zero_is_noop() {
318 let before = max_decoded_credential_bytes();
319 set_max_decoded_credential_bytes(0);
320 assert_eq!(max_decoded_credential_bytes(), before);
321 }
322
323 #[test]
324 fn check_authorized_hashes_full_pass_match() {
325 let valid = format!("admin:{SECRET_SHA256_HEX}");
326 let other = "user:0000000000000000000000000000000000000000000000000000000000000000";
327 let list = [other.to_owned(), valid.clone()];
328 assert!(check_authorized_hashes(&valid, &list));
329 }
330
331 #[test]
332 fn check_authorized_hashes_rejects_wrong_password() {
333 let wrong = "admin:0000000000000000000000000000000000000000000000000000000000000000";
334 let list = [format!("admin:{SECRET_SHA256_HEX}")];
335 assert!(!check_authorized_hashes(wrong, &list));
336 }
337
338 /// `pad_for_constant_time_compare` truncates inputs to
339 /// `AUTH_COMPARE_PAD_LEN = 256` bytes. Two canonical credentials whose
340 /// first 256 bytes match — e.g. the same long username with different
341 /// password digests at offsets > 256 — would otherwise produce
342 /// identical padded buffers and authenticate as each other. The fix
343 /// rejects any candidate whose canonical length exceeds the envelope
344 /// before the compare loop runs.
345 #[test]
346 fn check_authorized_hashes_rejects_overlong_candidate() {
347 // Username large enough that the `:hex64` suffix sits past byte 256.
348 // 250 bytes of `a` + ":" + 64 hex = 315 bytes total.
349 let long_user = "a".repeat(250);
350 let stored = format!("{long_user}:{SECRET_SHA256_HEX}");
351 let attacker =
352 format!("{long_user}:0000000000000000000000000000000000000000000000000000000000000000");
353 assert!(stored.len() > AUTH_COMPARE_PAD_LEN);
354 assert!(attacker.len() > AUTH_COMPARE_PAD_LEN);
355 assert_eq!(stored.len(), attacker.len()); // same length suffix
356 let list = [stored];
357 // Without the length guard the attacker credential would compare
358 // equal to `stored` because the differing digest is past byte 256
359 // and `pad_for_constant_time_compare` would truncate both inputs
360 // to the same prefix. The guard rejects both — no auth bypass.
361 assert!(!check_authorized_hashes(&attacker, &list));
362 }
363
364 /// Stored entries that are themselves overlong are skipped rather than
365 /// silently truncated. An operator config containing such an entry
366 /// would not authenticate any candidate against it; that is preferred
367 /// over admitting a collision.
368 #[test]
369 fn check_authorized_hashes_skips_overlong_stored_entry() {
370 let valid = format!("admin:{SECRET_SHA256_HEX}");
371 let overlong = format!("{}:{SECRET_SHA256_HEX}", "a".repeat(250));
372 assert!(overlong.len() > AUTH_COMPARE_PAD_LEN);
373 // The valid entry still matches; the overlong stored entry is skipped.
374 let list = [overlong, valid.clone()];
375 assert!(check_authorized_hashes(&valid, &list));
376 }
377
378 #[test]
379 fn check_authorized_hashes_rejects_when_list_empty() {
380 let candidate = format!("admin:{SECRET_SHA256_HEX}");
381 assert!(!check_authorized_hashes(&candidate, &[]));
382 }
383}