Skip to main content

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}