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
18 changes: 18 additions & 0 deletions crates/busbar-contract/src/auth_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,24 @@ pub trait AuthAxis: Send + Sync {
Ok(None)
}

/// [`AuthAxis::serving`] for a binding whose token endpoint the operator spelled with a
/// `user:pass@`: the host stripped it from `settings` before any plugin could read it, and
/// `endpoint_basic` is the `authorization: Basic` value it spelled, which the host presents on
/// the plugin's mint as 1.5.5 sent it. A plugin never receives it. Default: the value is not
/// presented.
///
/// # Errors
/// The serving plugin would not open for its outbound styles.
fn serving_presenting(
&self,
style: &str,
settings: &serde_json::Value,
endpoint_basic: Option<&[u8]>,
) -> Result<Option<OutboundServing>, String> {
let _ = endpoint_basic;
self.serving(style, settings)
}

/// CHECK, NEVER DIAL (the `--validate` dry run): bind `credential` under `settings` on a fresh
/// instance of the auth plugin serving `style`, one granted no need, and answer the refusals it
/// names for the credential itself (its `credential:` lines, their text), in its own words.
Expand Down
25 changes: 24 additions & 1 deletion crates/busbar-kernel/src/appbuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ pub fn stateful_plane_ephemeral_store_warn(
/// Map a provider's `Option<config::ProviderAuth>` to the NEUTRAL [`AuthStyleInput`] the carrier holds
/// (`None` ⇒ the protocol's native auth). The fallback plane maps it to the style its lane's credential
/// is bound under, on the auth plugin serving that style.
/// THE TOKEN ENDPOINT AS A PLANE MAY SEE IT (row 74, F25): `token_url` with its userinfo stripped,
/// and the `Authorization: Basic` value that userinfo spelled, held opaque for the host to present
/// on the mint as 1.5.5's reqwest POST did (v1.5.5
/// `crates/busbar/src/egress_auth/oauth_client_credentials.rs:119-126`). A plane never receives a
/// URL carrying `user:pass@`.
pub(crate) fn lane_token_endpoint(
written: Option<&str>,
) -> (Option<String>, Option<crate::egress::engine::UrlUserinfo>) {
let Some(written) = written else {
return (None, None);
};
(
Some(crate::net_guard::strip_userinfo(written).into_owned()),
crate::egress::engine::UrlUserinfo::of(written),
)
}

fn auth_style_of(auth: Option<config::ProviderAuth>) -> AuthStyleInput {
match auth {
None => AuthStyleInput::Default,
Expand Down Expand Up @@ -875,11 +892,16 @@ pub fn build_app_from_config(
let base_url =
crate::net_guard::strip_userinfo(provider_cfg.base_url.trim_end_matches('/'))
.into_owned();
// ...and the Basic that userinfo spelled, held opaque for the kernel's hop to present as
// 1.5.5 did (see `UrlUserinfo`): the plane never reads it.
let url_userinfo = crate::egress::engine::UrlUserinfo::of(&provider_cfg.base_url);
let (token_url, token_userinfo) = lane_token_endpoint(provider_cfg.token_url.as_deref());
lane_inputs.push(LaneInput {
model: ld.model.clone(),
provider: ld.provider.clone(),
protocol: protocol.to_string(),
base_url,
url_userinfo,
path: provider_cfg.path.clone(),
path_base: provider_cfg.path_base.clone(),
organization: provider_cfg.organization.clone(),
Expand All @@ -888,7 +910,8 @@ pub fn build_app_from_config(
api_key: busbar_contract::redacted::Redacted::new(api_key),
auth_style: auth_style_of(provider_cfg.auth),
scope: provider_cfg.scope.clone(),
token_url: provider_cfg.token_url.clone(),
token_url,
token_userinfo,
subject: provider_cfg.subject.clone(),
error_map: provider_cfg.error_map.clone(),
health: provider_cfg.health.as_ref().map(health_input_of),
Expand Down
27 changes: 26 additions & 1 deletion crates/busbar-kernel/src/bound_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,30 @@ pub fn bind(
axis: &dyn AuthAxis,
binding: &StyleBinding,
credential: &[u8],
) -> Result<Arc<dyn CredentialProvider>, String> {
bind_presenting(axis, binding, credential, None)
}

/// [`bind`] for a binding whose token endpoint the operator spelled with a `user:pass@` (row 74,
/// F25): `binding.params` names the stripped endpoint, and `endpoint` is the Basic value that
/// userinfo spelled, held opaque, handed to the axis that presents it on the mint as 1.5.5 sent it
/// ([`AuthAxis::serving_presenting`]). The plane that built the binding never reads it.
///
/// # Errors
///
/// As [`bind`].
pub fn bind_presenting(
axis: &dyn AuthAxis,
binding: &StyleBinding,
credential: &[u8],
endpoint: Option<&crate::egress::engine::UrlUserinfo>,
) -> Result<Arc<dyn CredentialProvider>, String> {
let serving = axis
.serving(&binding.style, &binding.params)?
.serving_presenting(
&binding.style,
&binding.params,
endpoint.map(|basic| basic.value().as_bytes()),
)?
.ok_or_else(|| {
format!(
"no linked or dropped-in auth plugin serves the style '{}'",
Expand Down Expand Up @@ -274,3 +295,7 @@ pub fn prebuild_auth(
credential.headers_for(api_key, &ctx),
))
}

#[cfg(test)]
#[path = "tests/bound_credential_tests.rs"]
mod tests;
9 changes: 5 additions & 4 deletions crates/busbar-kernel/src/config/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,11 @@ impl super::RootCfg {
.into_iter()
.filter(|(_, p)| !p.allow_metadata_hosts.is_empty())
.flat_map(|(_, p)| {
// The base URL as the served flow dials it: its userinfo stripped (row 74, F25), so
// the carve-out matches the host the dial names rather than reading nothing.
std::iter::once(crate::net_guard::strip_userinfo(&p.base_url).into_owned())
.chain(p.token_url.iter().cloned())
// Each URL as the served flow dials it: its userinfo stripped (row 74, F25), so the
// carve-out matches the host the dial names rather than reading nothing.
std::iter::once(&p.base_url)
.chain(&p.token_url)
.map(|url| crate::net_guard::strip_userinfo(url).into_owned())
.map(|url| (url, p.allow_metadata_hosts.clone()))
})
.collect();
Expand Down
15 changes: 13 additions & 2 deletions crates/busbar-kernel/src/config_validate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2383,7 +2383,11 @@ fn validate_providers_with(
|| scheme_is(tu, "https")
|| (host_private && scheme_is(tu, "http"));
if !tu_scheme_ok {
errors.push(if scheme_is(tu, "http") {
// Repeated with its userinfo redacted: a `user:pass@` in token_url is accepted
// and rides the mint as 1.5.5's did (row 74, F25), never a message.
let plaintext = scheme_is(tu, "http");
let tu = crate::net_guard::redact_userinfo(tu);
errors.push(if plaintext {
format!(
"provider '{provider_name}' token_url must use https for a public host (got '{tu}'); it carries the client secret, so plaintext http is permitted only for a private/loopback token endpoint"
)
Expand All @@ -2398,6 +2402,7 @@ fn validate_providers_with(
cfg.allow_all_metadata,
&cfg.blocked_metadata_hosts,
) {
let tu = crate::net_guard::redact_userinfo(tu);
errors.push(format!(
"provider '{provider_name}' token_url '{tu}' targets a blocked cloud-metadata host '{host}' (the client secret is POSTed there; cloud-metadata/IMDS endpoints are denied — override via this provider's allow_metadata_hosts, security.allow_metadata_hosts, or security.allow_all_metadata)"
));
Expand All @@ -2423,8 +2428,10 @@ fn validate_providers_with(
if !cred.trim().is_empty() {
// The credential's shape is the serving auth plugin's to judge, in its own words,
// through the same `open_outbound` the build binds it with (never dialing).
// The endpoint as the plugin is handed it at the build: its userinfo stripped.
let token_url = provider_cfg.token_url.as_deref().unwrap_or_default();
let params = serde_json::json!({
"token_url": provider_cfg.token_url.clone().unwrap_or_default(),
"token_url": crate::net_guard::strip_userinfo(token_url),
"scope": provider_cfg.scope.clone().unwrap_or_default(),
});
for e in outbound_credential_refusals("oauth-client-credentials", &cred, &params) {
Expand Down Expand Up @@ -2602,6 +2609,9 @@ pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), S
.map(host_is_private_or_loopback)
.unwrap_or(false);
if !(scheme_is(token_uri, "https") || (host_private && scheme_is(token_uri, "http"))) {
// Repeated with its userinfo redacted: a `user:pass@` in token_uri rides the mint as
// 1.5.5's did (row 74, F25), never a message.
let token_uri = crate::net_guard::redact_userinfo(token_uri);
return Err(format!(
"service-account token_uri must use https for a public host (got '{token_uri}'); it receives the signed JWT assertion, so plaintext http is permitted only for a private/loopback endpoint"
));
Expand All @@ -2612,6 +2622,7 @@ pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), S
posture.allow_all,
&posture.blocked_hosts,
) {
let token_uri = crate::net_guard::redact_userinfo(token_uri);
return Err(format!(
"service-account token_uri '{token_uri}' targets a blocked cloud-metadata host '{host}' (the signed assertion would be POSTed there; cloud-metadata/IMDS endpoints are denied — override via this provider's allow_metadata_hosts, security.allow_metadata_hosts, or security.allow_all_metadata)"
));
Expand Down
48 changes: 48 additions & 0 deletions crates/busbar-kernel/src/config_validate/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3267,6 +3267,54 @@ fn a_base_url_userinfo_is_accepted_and_no_validation_error_repeats_it() {
"each refusal still names the host it is about: {about:?}"
);
}
// The token endpoint (row 74, F25): accepted as 1.5.5 accepted it, and repeated redacted.
let with_token_url = |token_url: String| {
let mut providers = HashMap::new();
let mut p = make_provider(proto_b(), "https://api.example.com", "API_KEY");
p.token_url = Some(token_url);
p.scope = Some("api://x/.default".into());
p.auth = Some(config::ProviderAuth::OAuthClientCredentials);
providers.insert("myprovider".to_string(), p);
let mut models = HashMap::new();
models.insert("m".to_string(), make_model("myprovider", 10));
let mut pools = HashMap::new();
pools.insert("p".to_string(), make_pool(vec![make_member("m")]));
validate(&make_root_cfg(providers, models, pools))
};
let accepted = with_token_url(format!("https://{PLANTED}login.example.com/token"));
assert!(
accepted.is_ok(),
"a token_url spelling user:pass@ validates, as in 1.5.5: {accepted:?}"
);
let plaintext = format!("https://{PLANTED}login.example.com/token").replacen("s://", "://", 1);
for (token_url, host) in [
(
format!("ftp://{PLANTED}login.example.com/token"),
"login.example.com",
),
(plaintext, "login.example.com"),
(
format!("https://{PLANTED}169.254.169.254/token"),
"169.254.169.254",
),
] {
let errors = with_token_url(token_url).expect_err("the planted endpoint is refused");
let about: Vec<&String> = errors.iter().filter(|e| e.contains("token_url")).collect();
assert!(
!about.is_empty(),
"a token_url refusal was written: {errors:?}"
);
for e in &errors {
assert!(
!e.contains("hunter2") && !e.contains("svcuser"),
"a validation error repeated the planted spelling: {e}"
);
}
assert!(
about.iter().all(|e| e.contains(host)),
"each refusal still names the host it is about: {about:?}"
);
}
}

/// An EMPTY `affinity.header_name` must be REJECTED at boot. It passes
Expand Down
5 changes: 5 additions & 0 deletions crates/busbar-kernel/src/egress/engine/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ async fn send_request(
_ => return Err(EngineError::new(ErrorKind::UserUnsupportedVersion)),
}

// A provider base URL's userinfo, handed back by its plane as an opaque extension, rides here
// and nowhere earlier: the Basic field 1.5.5 sent, first, when the request names no
// `authorization` of its own (row 74, F25; see `super::UrlUserinfo`).
super::present_url_userinfo(&mut req);

// Preparation, part 2 — absolute-form required: scheme+authority IS the pool key. The CONNECT
// scheme-inference arm is unreachable for busbar (the tunnel lives below the connector) but
// kept for surface fidelity.
Expand Down
Loading