diff --git a/CHANGELOG.md b/CHANGELOG.md index 8755a1c..8425b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,14 @@ this crate adheres to [Semantic Versioning](https://semver.org/). rebuilt the router with the pre-cap-change semaphore, silently reverting the cap. Builder state is now assembled in one shared `router_state()` helper, so every `with_*` rebuild sees the current - configuration. + configuration. Pinned by the WS session-cap integration test running + with a builder call after `with_ws_max_sessions`, and by an + ownership-wiring-survives-rebuild test at the HTTP surface. +- Door-surface Basic parsing no longer requires the password field to + be UTF-8: the decoded password bytes reach + `IdentityProvider::resolve_from_token()` byte-faithfully, so RFC 7617 + legacy ISO-8859-1 credentials (e.g. a latin-1 user password) that a + provider resolves still authenticate instead of parsing to `None`. Minor bump 0.7.0 → 0.8.0 per the dep-wave convention (0.4.0 rode alkcall 0.7.0, 0.5.0 rode 0.8.0, 0.7.0 rode 0.9.0): the re-exported diff --git a/src/gateway/routes.rs b/src/gateway/routes.rs index ed9d613..524f956 100644 --- a/src/gateway/routes.rs +++ b/src/gateway/routes.rs @@ -1066,6 +1066,32 @@ mod tests { ); } + #[tokio::test] + async fn ownership_fallback_wiring_survives_a_later_builder_rebuild() { + let provider: Arc = + Arc::new(StaticIdentityProvider::new().with_token( + "tok-a", + identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])), + )); + let adapter = crate::server::HttpAdapter::new(provider, registry_with_namespace_gated_op()) + .with_static_ownership_fallback() + .with_decoy(crate::server::DecoyConfig::NotFound); + + let (status, body) = post_call( + adapter.router().clone(), + "tok-a", + "ns/stat", + json!({ "namespace": "ns-1" }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "the wiring must survive the with_decoy state rebuild — the pre-router_state() \ + builder shape silently dropped values set by earlier calls: {body}" + ); + } + fn registry_with_echo() -> Arc { let registry = OperationRegistry::new(); registry diff --git a/src/server/auth.rs b/src/server/auth.rs index a202849..a81b2c2 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -100,7 +100,7 @@ pub fn extract_bearer_identity( identity_provider: &dyn IdentityProvider, ) -> Option { resolve_token_str( - bearer_token(request.headers().get(AUTHORIZATION)), + bearer_token(request.headers().get(AUTHORIZATION)).map(String::into_bytes), identity_provider, ) } @@ -110,7 +110,10 @@ pub fn extract_bearer_identity( /// password field (the token; the Git-PAT shape — user, `x-access-token` /// and similar are ignored by resolution) through the same /// `IdentityProvider::resolve_from_token()` backend as a Bearer token. -/// A colon-less value is treated as a bare password. Unparseable or +/// The password bytes are passed to the backend with no UTF-8 +/// round-trip, so RFC 7617 legacy ISO-8859-1 credentials reach the +/// provider byte-faithfully. A colon-less value is treated as a bare +/// password. Unparseable or /// unresolvable credentials resolve to `None` — the same no-token /// fallback as [`extract_bearer_identity`]. pub fn extract_door_identity( @@ -119,7 +122,7 @@ pub fn extract_door_identity( ) -> Option { let header = request.headers().get(AUTHORIZATION)?; let credential = match bearer_token(Some(header)) { - Some(token) => token, + Some(token) => token.into_bytes(), None => basic_password(header)?, }; resolve_token_str(Some(credential), identity_provider) @@ -133,28 +136,25 @@ fn bearer_token(header: Option<&http::HeaderValue>) -> Option { .map(str::to_owned) } -fn basic_password(header: &http::HeaderValue) -> Option { +fn basic_password(header: &http::HeaderValue) -> Option> { use base64::Engine as _; let encoded = header.to_str().ok()?.strip_prefix("Basic ")?; let decoded = base64::engine::general_purpose::STANDARD .decode(encoded.trim()) .ok()?; - let decoded = String::from_utf8(decoded).ok()?; - let password = match decoded.split_once(':') { - Some((_user, password)) => password, - None => &decoded, + let password = match decoded.iter().position(|&byte| byte == b':') { + Some(colon) => &decoded[colon + 1..], + None => &decoded[..], }; - Some(password.to_owned()) + Some(password.to_vec()) } fn resolve_token_str( - token_str: Option, + token_bytes: Option>, identity_provider: &dyn IdentityProvider, ) -> Option { - let token = AuthToken { - raw: token_str?.into_bytes(), - }; + let token = AuthToken { raw: token_bytes? }; identity_provider.resolve_from_token(&token) } @@ -287,6 +287,28 @@ mod tests { assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some()); } + #[test] + fn door_extract_passes_non_utf8_basic_password_bytes_through() { + // RFC 7617 legacy ISO-8859-1 credential: "passwörd" (ö = 0xF6 in + // latin-1) — invalid UTF-8, must still reach `resolve_from_token` + // as the raw bytes rather than parsing to `None`. + struct ByteProvider; + impl IdentityProvider for ByteProvider { + fn resolve_from_fingerprint(&self, _: &str) -> Option { + None + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + (token.raw == b"passw\xf6rd"[..]).then(sample_identity) + } + } + let req = request_with_authorization(Some("Basic dXNlcjpwYXNzd/ZyZA==")); + let identity = crate::server::auth::extract_door_identity(&req, &ByteProvider); + assert!( + identity.is_some(), + "a latin-1 password resolves byte-faithfully" + ); + } + #[test] fn door_extract_treats_colonless_basic_as_a_bare_password() { struct BareProvider; diff --git a/tests/ws_upgrade_session.rs b/tests/ws_upgrade_session.rs index ed3ffc5..fa34bfd 100644 --- a/tests/ws_upgrade_session.rs +++ b/tests/ws_upgrade_session.rs @@ -806,9 +806,13 @@ async fn session_cap_rejects_over_limit_with_503_and_frees_slots_on_end() { } // The adapter's built-in surface: WS route + bearer middleware, the - // whole deployment shape the cap knob configures. - let adapter = - HttpAdapter::new(Arc::new(StaticTok), Arc::clone(®istry)).with_ws_max_sessions(1); + // whole deployment shape the cap knob configures. A builder call + // after `with_ws_max_sessions` must not revert the cap (the + // pre-`router_state()` builder shape rebuilt the router from the + // un-stored fresh semaphore). + let adapter = HttpAdapter::new(Arc::new(StaticTok), Arc::clone(®istry)) + .with_ws_max_sessions(1) + .with_decoy(alkhttp::server::DecoyConfig::default()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = format!("ws://{}", listener.local_addr().unwrap()); let app: axum::Router = adapter.router().clone();