use http::{HeaderMap, HeaderName, HeaderValue};
use zeroize::Zeroizing;
use crate::FilterError;
#[derive(Clone, Eq, PartialEq)]
struct CanonicalAuthority {
host: Box<str>,
port: u16,
}
fn parse_canonical(input: &str, default_port: Option<u16>) -> Result<CanonicalAuthority, FilterError> {
if input.is_empty() {
return Err("authority must not be empty".into());
}
let (host, port) = split_host_port(input)?;
if host.is_empty() {
return Err(format!("authority '{input}' has an empty host").into());
}
let port = port.or(default_port).ok_or_else(|| -> FilterError {
format!("authority '{input}' must specify an explicit port ('host:port')").into()
})?;
Ok(CanonicalAuthority {
host: host.to_ascii_lowercase().into_boxed_str(),
port,
})
}
fn split_host_port(input: &str) -> Result<(&str, Option<u16>), FilterError> {
if let Some(rest) = input.strip_prefix('[') {
let Some((host, after)) = rest.split_once(']') else {
return Err(format!("authority '{input}' has an unclosed IPv6 bracket").into());
};
match after.strip_prefix(':') {
Some(port_str) => Ok((host, Some(parse_port(port_str, input)?))),
None if after.is_empty() => Ok((host, None)),
None => Err(format!("authority '{input}' has trailing characters after ']'").into()),
}
} else if let Some((host, port_str)) = input.rsplit_once(':') {
if host.contains(':') {
Ok((input, None))
} else {
Ok((host, Some(parse_port(port_str, input)?)))
}
} else {
Ok((input, None))
}
}
fn parse_port(port_str: &str, input: &str) -> Result<u16, FilterError> {
port_str
.parse::<u16>()
.map_err(|e| -> FilterError { format!("authority '{input}' has an invalid port '{port_str}': {e}").into() })
}
fn canonical_host(input: &str) -> Result<Box<str>, FilterError> {
if input.is_empty() {
return Err("host must not be empty".into());
}
let host = if let Some(rest) = input.strip_prefix('[') {
let Some((host, after)) = rest.split_once(']') else {
return Err(format!("host '{input}' has an unclosed IPv6 bracket").into());
};
if !after.is_empty() {
return Err(format!("host wildcard '{input}' must not include a port").into());
}
host
} else if input.rsplit_once(':').is_some_and(|(host, _)| !host.contains(':')) {
return Err(format!("host wildcard '{input}' must not include a port").into());
} else {
input
};
if host.is_empty() {
return Err(format!("host '{input}' has an empty host").into());
}
Ok(host.to_ascii_lowercase().into_boxed_str())
}
fn forbidden_credential_header(header: &HeaderName) -> Option<&'static str> {
let name = header.as_str();
if name == http::header::HOST.as_str() {
Some("the routing Host header")
} else if name == http::header::CONTENT_LENGTH.as_str() {
Some("a message-framing header")
} else if praxis_core::reserved_headers::HOP_BY_HOP_HEADERS.contains(&name) {
Some("a hop-by-hop header")
} else {
None
}
}
enum CredentialScope {
Authority(CanonicalAuthority),
HostWildcard {
host: Box<str>,
},
}
impl CredentialScope {
fn matches(&self, resolved: &CanonicalAuthority) -> bool {
match self {
Self::Authority(authority) => *authority == *resolved,
Self::HostWildcard { host } => host.as_ref() == resolved.host.as_ref(),
}
}
}
pub(crate) struct ResolvedDestination<'a> {
pub(crate) authority: &'a str,
pub(crate) transport: &'a str,
}
impl ResolvedDestination<'_> {
fn canonicalize(&self) -> Option<CanonicalAuthority> {
let transport_port = parse_canonical(self.transport, None).ok()?.port;
parse_canonical(self.authority, Some(transport_port)).ok()
}
}
pub struct DeferredCredential {
scope: CredentialScope,
header: HeaderName,
value: Zeroizing<String>,
}
impl DeferredCredential {
pub fn new(authority: &str, header: HeaderName, value: impl Into<String>) -> Result<Self, FilterError> {
let authority = parse_canonical(authority, None)
.map_err(|error| -> FilterError { format!("deferred credential {error}").into() })?;
Self::build(CredentialScope::Authority(authority), header, value)
}
pub fn new_host_wildcard(host: &str, header: HeaderName, value: impl Into<String>) -> Result<Self, FilterError> {
let host =
canonical_host(host).map_err(|error| -> FilterError { format!("deferred credential {error}").into() })?;
Self::build(CredentialScope::HostWildcard { host }, header, value)
}
fn build(scope: CredentialScope, header: HeaderName, value: impl Into<String>) -> Result<Self, FilterError> {
if praxis_core::reserved_headers::is_reserved(header.as_str()) {
return Err(format!("deferred credential header '{header}' is reserved for internal use").into());
}
if let Some(class) = forbidden_credential_header(&header) {
return Err(
format!("deferred credential header '{header}' is {class} and cannot carry a credential").into(),
);
}
let value = Zeroizing::new(value.into());
HeaderValue::from_str(&value).map_err(|error| -> FilterError {
format!("deferred credential value is not a valid header value: {error}").into()
})?;
Ok(Self { scope, header, value })
}
fn inject_canonical(&self, resolved: &CanonicalAuthority, headers: &mut HeaderMap) -> bool {
if !self.scope.matches(resolved) {
return false;
}
let value = match HeaderValue::from_str(&self.value) {
Ok(value) => value,
Err(error) => {
tracing::warn!(
header = %self.header,
%error,
"deferred credential value failed re-validation at injection; \
dropping credential without sending (a pre-validated invariant regressed)"
);
return false;
},
};
headers.insert(self.header.clone(), value);
true
}
}
#[derive(Default)]
pub struct PendingCredentials {
credentials: Vec<DeferredCredential>,
}
impl PendingCredentials {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, credential: DeferredCredential) -> &mut Self {
self.credentials.push(credential);
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.credentials.is_empty()
}
pub(crate) fn inject_authorized(self, destination: &ResolvedDestination<'_>, headers: &mut HeaderMap) -> usize {
let Some(resolved) = destination.canonicalize() else {
return 0;
};
self.credentials
.iter()
.filter(|credential| credential.inject_canonical(&resolved, headers))
.count()
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "tests")]
mod tests {
use super::*;
fn credential(authority: &str, value: &str) -> DeferredCredential {
DeferredCredential::new(authority, HeaderName::from_static("authorization"), value.to_owned())
.expect("valid credential")
}
fn resolved(authority: &str) -> ResolvedDestination<'_> {
ResolvedDestination {
authority,
transport: authority,
}
}
fn inject_if_authorized(cred: &DeferredCredential, authority: &str, headers: &mut HeaderMap) -> bool {
resolved(authority)
.canonicalize()
.is_some_and(|resolved| cred.inject_canonical(&resolved, headers))
}
fn capture_logs<T, F: FnOnce() -> T>(f: F) -> (T, String) {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Buffer(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Buffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("buffer lock").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let buffer = Buffer(Arc::new(Mutex::new(Vec::new())));
let writer = buffer.clone();
let subscriber = tracing_subscriber::fmt()
.with_writer(move || writer.clone())
.with_ansi(false)
.with_max_level(tracing::Level::WARN)
.finish();
let out = tracing::subscriber::with_default(subscriber, f);
let bytes = buffer.0.lock().expect("buffer lock").clone();
(out, String::from_utf8_lossy(&bytes).into_owned())
}
#[test]
fn credential_injects_on_authority_match() {
let cred = credential("api.example.com:443", "Bearer sk-secret");
let mut headers = HeaderMap::new();
let injected = inject_if_authorized(&cred, "api.example.com:443", &mut headers);
assert!(injected, "matching authority should inject the credential");
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
Some("Bearer sk-secret"),
"the secret header should be present for the authorized destination"
);
}
#[test]
fn credential_dropped_on_authority_mismatch() {
let cred = credential("api.example.com:443", "Bearer sk-secret");
let mut headers = HeaderMap::new();
let injected = inject_if_authorized(&cred, "evil.example.com:443", &mut headers);
assert!(!injected, "mismatched authority must not inject the credential");
assert!(
!headers.contains_key("authorization"),
"the secret must never reach an unauthorized destination"
);
}
#[test]
fn credential_replaces_client_supplied_value() {
let cred = credential("api.example.com:443", "Bearer gateway-managed");
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer client-supplied"));
let injected = inject_if_authorized(&cred, "api.example.com:443", &mut headers);
assert!(injected);
assert_eq!(
headers.get_all("authorization").iter().count(),
1,
"injection must replace, not append to, a client-supplied credential"
);
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
Some("Bearer gateway-managed"),
);
}
#[test]
fn pending_credentials_inject_only_authorized() {
let mut pending = PendingCredentials::new();
pending.push(credential("api.example.com:443", "Bearer for-api")).push(
DeferredCredential::new(
"other.example.com:443",
HeaderName::from_static("x-api-key"),
"for-other",
)
.unwrap(),
);
assert!(!pending.is_empty());
let mut headers = HeaderMap::new();
let injected = pending.inject_authorized(&resolved("api.example.com:443"), &mut headers);
assert_eq!(
injected, 1,
"only the credential bound to the resolved authority injects"
);
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
Some("Bearer for-api"),
);
assert!(
!headers.contains_key("x-api-key"),
"a credential for a different authority must not be materialized"
);
}
#[test]
fn deferred_credential_rejects_host_only_authority() {
let err = DeferredCredential::new(
"api.example.com",
HeaderName::from_static("authorization"),
"Bearer sk-secret",
)
.err()
.expect("a host-only authority is ambiguous (which port?) and must be rejected");
assert!(
err.to_string().contains("port"),
"the error must explain that an explicit port is required: {err}"
);
}
#[test]
fn credential_authority_match_is_case_insensitive() {
let cred = credential("API.Example.COM:443", "Bearer sk-secret");
let mut headers = HeaderMap::new();
let injected = inject_if_authorized(&cred, "api.example.com:443", &mut headers);
assert!(
injected,
"host comparison must be case-insensitive after canonicalization"
);
}
#[test]
fn credential_host_wildcard_matches_any_port() {
let cred = DeferredCredential::new_host_wildcard(
"api.example.com",
HeaderName::from_static("authorization"),
"Bearer sk-secret",
)
.expect("a bare host is a valid wildcard scope");
for authority in ["api.example.com:443", "api.example.com:8443"] {
let mut headers = HeaderMap::new();
assert!(
inject_if_authorized(&cred, authority, &mut headers),
"a host wildcard must match any port on its host: {authority}"
);
}
let mut headers = HeaderMap::new();
assert!(
!inject_if_authorized(&cred, "evil.example.com:443", &mut headers),
"a host wildcard must not match a different host"
);
}
#[test]
fn deferred_credential_host_wildcard_rejects_port() {
let err = DeferredCredential::new_host_wildcard(
"api.example.com:443",
HeaderName::from_static("authorization"),
"Bearer sk-secret",
)
.err()
.expect("a wildcard host must not carry a port");
assert!(
err.to_string().contains("port"),
"the error must explain that a wildcard host must not include a port: {err}"
);
}
#[test]
fn credential_uses_transport_port_when_authority_omits_it() {
let mut pending = PendingCredentials::new();
pending.push(credential("api.example.com:443", "Bearer sk-secret"));
let mut headers = HeaderMap::new();
let injected = pending.inject_authorized(
&ResolvedDestination {
authority: "api.example.com",
transport: "10.0.0.5:443",
},
&mut headers,
);
assert_eq!(
injected, 1,
"a bare logical authority must inherit the transport port to match a :443 credential"
);
let mut pending = PendingCredentials::new();
pending.push(credential("api.example.com:443", "Bearer sk-secret"));
let mut headers = HeaderMap::new();
let injected = pending.inject_authorized(
&ResolvedDestination {
authority: "api.example.com",
transport: "10.0.0.5:8443",
},
&mut headers,
);
assert_eq!(
injected, 0,
"the transport port (8443) must not match a credential bound to :443"
);
}
#[test]
fn deferred_credential_rejects_reserved_header() {
let err = DeferredCredential::new(
"api.example.com:443",
HeaderName::from_static("x-praxis-subrequest-depth"),
"value",
)
.err()
.expect("a reserved header must be rejected");
assert!(err.to_string().contains("reserved"), "{err}");
}
#[test]
fn deferred_credential_rejects_host_header() {
let err = DeferredCredential::new(
"api.example.com:443",
HeaderName::from_static("host"),
"evil.example.com",
)
.err()
.expect("Host must be rejected: a credential must not retarget the request after authorization");
assert!(
err.to_string().to_ascii_lowercase().contains("host"),
"the error must name the Host header: {err}"
);
}
#[test]
fn deferred_credential_rejects_framing_and_hop_by_hop_headers() {
for name in [
"content-length",
"transfer-encoding",
"connection",
"proxy-authorization",
] {
let err = DeferredCredential::new("api.example.com:443", HeaderName::from_static(name), "1")
.err()
.unwrap_or_else(|| panic!("'{name}' must be rejected as a framing/hop-by-hop credential header"));
assert!(
!err.to_string().is_empty(),
"'{name}' rejection must carry an explanatory error"
);
}
}
#[test]
fn deferred_credential_rejects_invalid_value() {
let err = DeferredCredential::new(
"api.example.com:443",
HeaderName::from_static("authorization"),
"bad\nvalue",
)
.err()
.expect("a value with control characters must be rejected");
assert!(err.to_string().contains("valid header value"), "{err}");
}
#[test]
fn credential_matches_bracketed_ipv6_authority() {
let cred = credential("[::1]:443", "Bearer sk-secret");
let mut headers = HeaderMap::new();
let injected = inject_if_authorized(&cred, "[::1]:443", &mut headers);
assert!(injected, "a bracketed IPv6 authority must match itself");
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
Some("Bearer sk-secret"),
);
}
#[test]
fn credential_host_wildcard_matches_bracketed_ipv6_any_port() {
let cred = DeferredCredential::new_host_wildcard(
"[::1]",
HeaderName::from_static("authorization"),
"Bearer sk-secret",
)
.expect("a bracketed IPv6 literal is a valid wildcard host");
for authority in ["[::1]:443", "[::1]:8443"] {
let mut headers = HeaderMap::new();
assert!(
inject_if_authorized(&cred, authority, &mut headers),
"an IPv6 host wildcard must match any port on its host: {authority}"
);
}
}
#[test]
fn credential_host_wildcard_accepts_unbracketed_ipv6() {
let cred =
DeferredCredential::new_host_wildcard("::1", HeaderName::from_static("authorization"), "Bearer sk-secret")
.expect("an unbracketed IPv6 literal is accepted as a wildcard host");
let mut headers = HeaderMap::new();
assert!(
inject_if_authorized(&cred, "[::1]:443", &mut headers),
"an unbracketed IPv6 wildcard must match the same literal on any port"
);
}
#[test]
fn deferred_credential_rejects_unbracketed_ipv6_authority() {
let err = DeferredCredential::new("::1", HeaderName::from_static("authorization"), "Bearer sk-secret")
.err()
.expect("an unbracketed IPv6 literal carries no separable port and must be rejected");
assert!(
err.to_string().contains("port"),
"the error must explain that an explicit port is required: {err}"
);
}
#[test]
fn deferred_credential_rejects_bracketed_ipv6_without_port() {
let err = DeferredCredential::new("[::1]", HeaderName::from_static("authorization"), "Bearer sk-secret")
.err()
.expect("a bracketed IPv6 literal without a port must be rejected");
assert!(
err.to_string().contains("port"),
"the error must explain that an explicit port is required: {err}"
);
}
#[test]
fn deferred_credential_host_wildcard_rejects_bracketed_ipv6_with_port() {
let err = DeferredCredential::new_host_wildcard(
"[::1]:443",
HeaderName::from_static("authorization"),
"Bearer sk-secret",
)
.err()
.expect("a bracketed IPv6 wildcard host must not carry a port");
assert!(
err.to_string().contains("port"),
"the error must explain that a wildcard host must not include a port: {err}"
);
}
#[test]
fn inject_warns_and_drops_when_prevalidated_value_regresses() {
let cred = DeferredCredential {
scope: CredentialScope::Authority(parse_canonical("api.example.com:443", None).expect("valid authority")),
header: HeaderName::from_static("authorization"),
value: Zeroizing::new("SENTINELSECRET\nx".to_owned()),
};
let resolved = resolved("api.example.com:443")
.canonicalize()
.expect("resolvable destination");
let mut headers = HeaderMap::new();
let (injected, logs) = capture_logs(|| cred.inject_canonical(&resolved, &mut headers));
assert!(!injected, "a regressed invariant must degrade to a no-op, not inject");
assert!(
!headers.contains_key("authorization"),
"no header may be written on the invariant-violation fallback path"
);
assert!(
logs.contains("WARN"),
"the broken invariant must be narrated at warn level, not silently dropped: {logs:?}"
);
assert!(
!logs.contains("SENTINELSECRET"),
"the diagnostic must never leak the secret value: {logs:?}"
);
}
}