Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions crates/core/src/auth/scheme.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
56 changes: 50 additions & 6 deletions crates/core/src/auth/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 1 addition & 4 deletions crates/server/src/auth/token_exchange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>, headers: HeaderMap, body: Bytes) -> Response {
Expand Down
25 changes: 8 additions & 17 deletions crates/server/src/auth/wif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -993,13 +994,12 @@ impl PrincipalSource for WorkloadIdentitySource {
fn candidate_secrets(headers: &HeaderMap) -> Vec<String> {
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);
}
Expand All @@ -1018,17 +1018,8 @@ fn candidate_secrets(headers: &HeaderMap) -> Vec<String> {
/// The username of an HTTP Basic `Authorization` header, if one is present.
fn basic_username(headers: &HeaderMap) -> Option<String> {
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.
Expand Down
12 changes: 6 additions & 6 deletions crates/server/src/images/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 1 addition & 9 deletions crates/server/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions crates/server/tests/trusted_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
6 changes: 6 additions & 0 deletions docs/changes/2.2.0/584.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading