From a8510a429b1b12bbb732c9ef1aaa75cad46ed243 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:48:55 +0000 Subject: [PATCH] Read the Authorization scheme case-insensitively in every credential source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization schemes are case-insensitive (RFC 9110 §11.1), but the Basic source read only `Basic ` and `basic `: `Authorization: BASIC ` was no credential to the middleware, so behind a trusted-header realm's proxy the headers named the caller instead of the request's own password being checked. Add forge_core::auth::scheme, one helper that reads an Authorization value's credentials for a scheme in any case, and use it in the Basic source, the workload-identity source, the token exchange, the npm Bearer token on feeds and the registry's Bearer token, instead of their copies. Fixes #584 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJbqjKPPeknBMf5kEFg --- crates/core/src/auth/mod.rs | 2 + crates/core/src/auth/scheme.rs | 66 ++++++++++++++++++++++++ crates/core/src/auth/token.rs | 56 +++++++++++++++++--- crates/server/src/auth/token_exchange.rs | 5 +- crates/server/src/auth/wif.rs | 25 +++------ crates/server/src/images/dispatch.rs | 12 ++--- crates/server/src/plugins.rs | 10 +--- crates/server/tests/trusted_header.rs | 45 ++++++++++++++++ docs/changes/2.2.0/584.fixed.md | 6 +++ 9 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 crates/core/src/auth/scheme.rs create mode 100644 docs/changes/2.2.0/584.fixed.md diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 71a75a0e..f416cc5a 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -18,6 +18,7 @@ //! fail-closed. //! * [`login_throttle`] — per `(client IP, username)` password-failure lockout //! shared by the browser login and the wire Basic password path. +//! * [`scheme`] — the case-insensitive scheme of an `Authorization` header. //! * [`workload_key`] — the short-lived API keys the trusted-publishing token //! exchange issues for CI workloads. @@ -30,6 +31,7 @@ pub mod local_password; pub mod login_throttle; pub mod principal; pub mod provider; +pub mod scheme; pub mod session_source; pub mod token; pub mod upstream_basic; diff --git a/crates/core/src/auth/scheme.rs b/crates/core/src/auth/scheme.rs new file mode 100644 index 00000000..524cb0d4 --- /dev/null +++ b/crates/core/src/auth/scheme.rs @@ -0,0 +1,66 @@ +//! Reading the scheme of an `Authorization` header. +//! +//! An authentication scheme is a case-insensitive token (RFC 9110 §11.1): +//! `Basic`, `basic` and `BASIC` name the same scheme. Every reader of the +//! request's own credentials goes through [`credentials`], so none of them +//! takes a credential in another case for no credential at all (#584) — +//! behind a trusted-header realm's proxy the headers would then name the +//! caller instead of the request's own credential being checked. + +use http::HeaderMap; + +/// The credentials of an `Authorization` value of `scheme`, trimmed: the +/// text after the scheme and the space that ends it, when the value's scheme +/// is `scheme` in any case. `None` for another scheme, or a scheme alone. +/// The credentials may be empty (`Basic `); the caller decides what that is. +pub fn credentials<'a>(value: &'a str, scheme: &str) -> Option<&'a str> { + let (name, rest) = value.split_once(' ')?; + name.eq_ignore_ascii_case(scheme).then(|| rest.trim()) +} + +/// The non-empty credentials of the request's `Authorization` header when +/// its scheme is `scheme` in any case ([`credentials`]). +pub fn header_credentials<'a>(headers: &'a HeaderMap, scheme: &str) -> Option<&'a str> { + let value = headers.get(http::header::AUTHORIZATION)?.to_str().ok()?; + credentials(value, scheme).filter(|c| !c.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_scheme_is_read_in_any_case() { + for value in [ + "Basic abc", + "basic abc", + "BASIC abc", + "bAsIc abc", + "Basic abc ", + ] { + assert_eq!(credentials(value, "Basic"), Some("abc"), "{value}"); + } + for value in ["Bearer t", "bearer t", "BEARER t", "bEaReR t"] { + assert_eq!(credentials(value, "Bearer"), Some("t"), "{value}"); + } + } + + #[test] + fn another_scheme_or_a_scheme_alone_is_none() { + assert_eq!(credentials("Bearer abc", "Basic"), None); + assert_eq!(credentials("Basicabc", "Basic"), None); + assert_eq!(credentials("Basic", "Basic"), None); + assert_eq!(credentials("Basic ", "Basic"), Some("")); + } + + #[test] + fn header_credentials_are_never_empty() { + let mut headers = HeaderMap::new(); + assert_eq!(header_credentials(&headers, "Bearer"), None); + headers.insert(http::header::AUTHORIZATION, "BEARER tok".parse().unwrap()); + assert_eq!(header_credentials(&headers, "bearer"), Some("tok")); + assert_eq!(header_credentials(&headers, "Basic"), None); + headers.insert(http::header::AUTHORIZATION, "Bearer ".parse().unwrap()); + assert_eq!(header_credentials(&headers, "Bearer"), None); + } +} diff --git a/crates/core/src/auth/token.rs b/crates/core/src/auth/token.rs index d9b9ae1e..a2bd7797 100644 --- a/crates/core/src/auth/token.rs +++ b/crates/core/src/auth/token.rs @@ -353,11 +353,9 @@ impl BasicAuthSource { /// A username over [`MAX_USERNAME_BYTES`] is refused (`None` → invalid /// credentials) before it reaches any lookup or the login throttle. fn parse_basic(value: &str) -> Option<(String, String)> { - let rest = value - .strip_prefix("Basic ") - .or_else(|| value.strip_prefix("basic "))?; + let rest = super::scheme::credentials(value, "Basic")?; let decoded = base64::engine::general_purpose::STANDARD - .decode(rest.trim()) + .decode(rest) .ok()?; let text = String::from_utf8(decoded).ok()?; // NuGet sends `username:apikey`; the colon splits user from secret. A secret @@ -378,8 +376,9 @@ impl PrincipalSource for BasicAuthSource { let Ok(text) = value.to_str() else { return SourceOutcome::Invalid; }; - // Only handle the Basic scheme; other schemes are "no credentials" for us. - if !(text.starts_with("Basic ") || text.starts_with("basic ")) { + // Only handle the Basic scheme, in any case (RFC 9110 §11.1); other + // schemes are "no credentials" for us. + if super::scheme::credentials(text, "Basic").is_none() { return SourceOutcome::NoCredentials; } let Some((username, secret)) = parse_basic(text) else { @@ -727,6 +726,51 @@ mod tests { assert_eq!(s, "a:b:c"); } + #[test] + fn parse_basic_reads_the_scheme_in_any_case() { + // RFC 9110 §11.1: the scheme is case-insensitive (#584). + let enc = base64::engine::general_purpose::STANDARD.encode("alice:s3cr3t"); + for scheme in ["Basic", "basic", "BASIC", "bAsIc"] { + let (u, s) = parse_basic(&format!("{scheme} {enc}")).unwrap(); + assert_eq!((u.as_str(), s.as_str()), ("alice", "s3cr3t"), "{scheme}"); + } + assert_eq!(parse_basic(&format!("Bearer {enc}")), None); + } + + /// `BASIC` is a credential of the Basic source, never "no credentials" + /// (#584): one it cannot read is invalid (→ 401), not left for a + /// trusted-header realm to name the caller. Neither reaches the database. + #[tokio::test] + async fn basic_source_reads_the_scheme_in_any_case() { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://forge:forge@localhost:1/forge") + .unwrap(); + let source = BasicAuthSource::with_chain(pool, None); + let enc = base64::engine::general_purpose::STANDARD.encode("alice:"); + for value in [ + "BASIC !!!".to_string(), + "bAsIc !!!".to_string(), + format!("BASIC {enc}"), + ] { + let mut headers = HeaderMap::new(); + headers.insert(http::header::AUTHORIZATION, value.parse().unwrap()); + assert_eq!( + source.resolve(&headers, ip()).await, + SourceOutcome::Invalid, + "{value}" + ); + } + for value in ["Bearer abc", "BEARER abc", "Basic"] { + let mut headers = HeaderMap::new(); + headers.insert(http::header::AUTHORIZATION, value.parse().unwrap()); + assert_eq!( + source.resolve(&headers, ip()).await, + SourceOutcome::NoCredentials, + "{value}" + ); + } + } + // ── downgrade-prevention: pure unit tests with stub sources ────────────── struct StubSource(SourceOutcome); diff --git a/crates/server/src/auth/token_exchange.rs b/crates/server/src/auth/token_exchange.rs index de5166ae..b42f86a7 100644 --- a/crates/server/src/auth/token_exchange.rs +++ b/crates/server/src/auth/token_exchange.rs @@ -137,10 +137,7 @@ fn refused(status: StatusCode, message: &'static str) -> Response { /// The Bearer token of `headers`, if the request carries one. fn bearer_token(headers: &HeaderMap) -> Option<&str> { - let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; - let (scheme, token) = value.split_once(' ')?; - let token = token.trim(); - (scheme.eq_ignore_ascii_case("bearer") && !token.is_empty()).then_some(token) + forge_core::auth::scheme::header_credentials(headers, "Bearer") } async fn exchange(State(state): State, headers: HeaderMap, body: Bytes) -> Response { diff --git a/crates/server/src/auth/wif.rs b/crates/server/src/auth/wif.rs index a2422287..e0037fd5 100644 --- a/crates/server/src/auth/wif.rs +++ b/crates/server/src/auth/wif.rs @@ -30,6 +30,7 @@ use std::time::Duration; use async_trait::async_trait; use base64::Engine; use forge_cache::Cache; +use forge_core::auth::scheme::credentials; use forge_core::auth::token::{PrincipalSource, SourceOutcome}; use forge_core::auth::{is_workload_key, WORKLOAD_USERNAME}; use forge_core::auth::{Principal, TrustedPublisherMatch}; @@ -993,13 +994,12 @@ impl PrincipalSource for WorkloadIdentitySource { fn candidate_secrets(headers: &HeaderMap) -> Vec { let mut out = Vec::new(); if let Some(auth) = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()) { - if let Some(bearer) = strip_prefix_ci(auth, "Bearer ") { - let t = bearer.trim(); - if !t.is_empty() { - out.push(t.to_string()); + if let Some(bearer) = credentials(auth, "Bearer") { + if !bearer.is_empty() { + out.push(bearer.to_string()); } - } else if let Some(basic) = strip_prefix_ci(auth, "Basic ") { - if let Some((_, pw)) = basic_credentials(basic.trim()) { + } else if let Some(basic) = credentials(auth, "Basic") { + if let Some((_, pw)) = basic_credentials(basic) { if !pw.is_empty() { out.push(pw); } @@ -1018,17 +1018,8 @@ fn candidate_secrets(headers: &HeaderMap) -> Vec { /// The username of an HTTP Basic `Authorization` header, if one is present. fn basic_username(headers: &HeaderMap) -> Option { let auth = headers.get(AUTHORIZATION)?.to_str().ok()?; - let basic = strip_prefix_ci(auth, "Basic ")?; - basic_credentials(basic.trim()).map(|(username, _)| username) -} - -/// Case-insensitive prefix strip (HTTP auth schemes are case-insensitive). -fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { - if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) { - Some(&s[prefix.len()..]) - } else { - None - } + let basic = credentials(auth, "Basic")?; + basic_credentials(basic).map(|(username, _)| username) } /// Decode a Base64 `user:password` HTTP Basic credential into its halves. diff --git a/crates/server/src/images/dispatch.rs b/crates/server/src/images/dispatch.rs index b026f8f5..30a10a33 100644 --- a/crates/server/src/images/dispatch.rs +++ b/crates/server/src/images/dispatch.rs @@ -1064,12 +1064,7 @@ fn strip_port(host: &str) -> &str { /// The `Authorization: Bearer` value, if any. pub(crate) fn bearer_token(headers: &HeaderMap) -> Option<&str> { - let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; - let (scheme, token) = value.split_once(' ')?; - scheme - .eq_ignore_ascii_case("bearer") - .then(|| token.trim()) - .filter(|t| !t.is_empty()) + forge_core::auth::scheme::header_credentials(headers, "Bearer") } /// Whether the principal is a credential a push may come from: an API @@ -1224,6 +1219,11 @@ mod tests { HeaderValue::from_static("Bearer abc"), ); assert_eq!(bearer_token(&h), Some("abc")); + // The scheme is case-insensitive (RFC 9110 §11.1, #584). + for value in ["bearer abc", "BEARER abc", "bEaReR abc"] { + h.insert(header::AUTHORIZATION, HeaderValue::from_static(value)); + assert_eq!(bearer_token(&h), Some("abc"), "{value}"); + } h.insert(header::AUTHORIZATION, HeaderValue::from_static("Basic abc")); assert_eq!(bearer_token(&h), None); } diff --git a/crates/server/src/plugins.rs b/crates/server/src/plugins.rs index 7bd11b6b..4a8d29d4 100644 --- a/crates/server/src/plugins.rs +++ b/crates/server/src/plugins.rs @@ -236,15 +236,7 @@ async fn with_bearer( ) { return Ok(principal); } - let Some(token) = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| { - let (scheme, token) = v.split_once(' ')?; - scheme.eq_ignore_ascii_case("bearer").then(|| token.trim()) - }) - .filter(|token| !token.is_empty()) - else { + let Some(token) = forge_core::auth::scheme::header_credentials(headers, "Bearer") else { return Ok(principal); }; match check_token(&state.pool, token).await { diff --git a/crates/server/tests/trusted_header.rs b/crates/server/tests/trusted_header.rs index 36b2af4c..4d189e45 100644 --- a/crates/server/tests/trusted_header.rs +++ b/crates/server/tests/trusted_header.rs @@ -808,3 +808,48 @@ async fn a_denied_request_the_headers_name_is_challenged() { let resp = index().basic_auth(&u, Some(&p)).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + +/// The Basic scheme in another case (`BASIC`) is the request's own +/// credential too (#584, RFC 9110 §11.1): with the right password it names +/// its own account over the headers, and with a wrong one it is a 401 — +/// never the account the headers name. +#[tokio::test] +async fn basic_in_another_case_is_the_requests_own_credential() { + use base64::Engine; + + let srv = proxied().await; + let realm = create_realm(&srv, false).await; + let n = common::unique(); + let office = format!("office{n}@example.com"); + let office_id = create_account(&srv, &realm, &office, "roles_only").await; + let (u, p) = srv.admin_basic(); + let basic = |scheme: &str, password: &str| { + let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{u}:{password}")); + format!("{scheme} {encoded}") + }; + + // The headers alone name the network's account. + let (status, body) = me(realm.named(me_request(&srv), &office)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["user"]["id"], json!(office_id)); + + for scheme in ["BASIC", "bAsIc"] { + // A wrong password is a 401, not the account the headers name… + assert_eq!( + me(realm + .named(me_request(&srv), &office) + .header("Authorization", basic(scheme, "wrong-password"))) + .await, + (StatusCode::UNAUTHORIZED, json!(INVALID)), + "{scheme}" + ); + // …and the right one names the request's own account. + let (status, body) = me(realm + .named(me_request(&srv), &office) + .header("Authorization", basic(scheme, &p))) + .await; + assert_eq!(status, StatusCode::OK, "{scheme}: {body}"); + assert_ne!(body["user"]["id"], json!(office_id), "{scheme}: {body}"); + assert_eq!(body["is_admin"], json!(true), "{scheme}: {body}"); + } +} diff --git a/docs/changes/2.2.0/584.fixed.md b/docs/changes/2.2.0/584.fixed.md new file mode 100644 index 00000000..7d2d0d15 --- /dev/null +++ b/docs/changes/2.2.0/584.fixed.md @@ -0,0 +1,6 @@ +An `Authorization` scheme in another case than `Basic` / `basic`, such as +`BASIC`, was no credential (schemes are case-insensitive): the request was +anonymous, and behind a trusted-header realm's proxy the headers named the +caller instead of the request's own password being checked. Every +credential source now reads the scheme in any case, so a wrong password is +a 401 there.