use hyper::header::{HeaderName, HeaderValue};
pub(crate) const MAX_AUTH_HEADER_INPUT_BYTES: usize = 8 * 1024;
const FORBIDDEN_HEADER_NAMES: &[&str] = &[
"host",
"content-length",
"transfer-encoding",
"connection",
"upgrade",
"te",
"proxy-connection",
];
#[derive(Clone)]
pub struct AuthHeader {
pub(crate) name: HeaderName,
pub(crate) value: HeaderValue,
}
impl AuthHeader {
pub fn parse(raw: &str) -> Result<Self, &'static str> {
if raw.len() > MAX_AUTH_HEADER_INPUT_BYTES {
return Err("auth header exceeds 8 KiB input cap");
}
let (name_raw, value_raw) = raw
.split_once(':')
.ok_or("auth header must be 'Name: Value' format")?;
let name_trimmed = name_raw.trim();
if name_trimmed.is_empty() {
return Err("auth header name is empty");
}
let name = HeaderName::from_bytes(name_trimmed.as_bytes())
.map_err(|_| "invalid auth header name")?;
if FORBIDDEN_HEADER_NAMES
.iter()
.any(|forbidden| name.as_str().eq_ignore_ascii_case(forbidden))
{
return Err(
"auth header name not permitted (hop-by-hop, authority, or framing header)",
);
}
let value_trimmed = value_raw.trim();
if value_trimmed.is_empty() {
return Err("auth header value is empty");
}
let mut value = HeaderValue::from_str(value_trimmed)
.map_err(|_| "invalid auth header value (CR, LF or non-visible ASCII forbidden)")?;
value.set_sensitive(true);
Ok(Self { name, value })
}
}
#[cfg(feature = "daemon")]
#[derive(Debug)]
pub(crate) enum ScraperAuthOutcome {
None,
Some(AuthHeader),
Invalid,
}
#[cfg(feature = "daemon")]
pub(crate) fn parse_scraper_auth_header(
raw: Option<&str>,
endpoint: &str,
redacted_endpoint: &str,
subsystem: &'static str,
) -> ScraperAuthOutcome {
let parsed = match raw.map(AuthHeader::parse).transpose() {
Ok(v) => v,
Err(msg) => {
tracing::error!(
subsystem = subsystem,
endpoint = redacted_endpoint,
reason = msg,
"Scraper disabled, invalid auth_header"
);
return ScraperAuthOutcome::Invalid;
}
};
match parsed {
Some(header) => {
if endpoint.starts_with("http://") {
tracing::warn!(
"Sending auth header over cleartext HTTP, prefer https:// to avoid credential leak"
);
}
ScraperAuthOutcome::Some(header)
}
None => ScraperAuthOutcome::None,
}
}
impl std::fmt::Debug for AuthHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthHeader")
.field("name", &self.name)
.field("value", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bearer_line() {
let auth = AuthHeader::parse("Authorization: Bearer abc123").expect("valid header");
assert_eq!(auth.name.as_str(), "authorization");
assert!(auth.value.is_sensitive());
}
#[test]
fn parses_custom_header() {
let auth = AuthHeader::parse("X-API-Key: secret").expect("valid header");
assert_eq!(auth.name.as_str(), "x-api-key");
}
#[test]
fn trims_surrounding_whitespace() {
let auth = AuthHeader::parse(" Authorization : Bearer foo ").expect("valid");
assert_eq!(auth.name.as_str(), "authorization");
assert_eq!(auth.value.to_str().expect("visible ascii"), "Bearer foo");
}
#[test]
fn rejects_missing_colon() {
assert!(AuthHeader::parse("NoColonHere").is_err());
}
#[test]
fn rejects_invalid_name() {
assert!(AuthHeader::parse("Bad Name: value").is_err());
}
#[test]
fn rejects_empty_name() {
let err = AuthHeader::parse(": value").expect_err("empty name must fail");
assert!(err.contains("name"));
}
#[test]
fn rejects_empty_value() {
let err = AuthHeader::parse("Authorization: ").expect_err("empty value must fail");
assert!(err.contains("value"));
let err = AuthHeader::parse("Authorization:").expect_err("empty value must fail");
assert!(err.contains("value"));
}
#[test]
fn rejects_crlf_in_value() {
assert!(AuthHeader::parse("X: a\r\nY: b").is_err());
}
#[test]
fn preserves_internal_tabs_and_spaces() {
let auth = AuthHeader::parse("Authorization: Bearer\tfoo bar").expect("valid");
assert_eq!(
auth.value.to_str().expect("visible ascii"),
"Bearer\tfoo bar"
);
}
#[test]
fn rejects_oversized_input() {
let huge = format!("X: {}", "a".repeat(MAX_AUTH_HEADER_INPUT_BYTES));
let err = AuthHeader::parse(&huge).expect_err("over-cap input must fail");
assert!(err.contains("cap") || err.contains("8 KiB"));
}
#[test]
fn rejects_host_header() {
let err = AuthHeader::parse("Host: attacker.com").expect_err("Host must be blocked");
assert!(err.contains("not permitted"));
}
#[test]
fn rejects_content_length() {
assert!(AuthHeader::parse("Content-Length: 0").is_err());
}
#[test]
fn rejects_transfer_encoding() {
assert!(AuthHeader::parse("Transfer-Encoding: chunked").is_err());
}
#[test]
fn rejects_connection_header() {
assert!(AuthHeader::parse("Connection: upgrade").is_err());
}
#[test]
fn forbidden_check_is_case_insensitive() {
assert!(AuthHeader::parse("HOST: x").is_err());
assert!(AuthHeader::parse("host: x").is_err());
assert!(AuthHeader::parse("Host: x").is_err());
}
#[test]
fn debug_redacts_value() {
let auth = AuthHeader::parse("Authorization: Bearer topsecret").expect("valid");
let dbg = format!("{auth:?}");
assert!(dbg.contains("<redacted>"));
assert!(!dbg.contains("topsecret"));
}
}