use rand::Rng;
use wafrift_types::canary::Canary;
use wafrift_types::pick::pick_from;
use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
pub const MAX_AUTH_HEADER_BYTES: usize = 4 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AuthHeaderVariant {
LowercaseScheme,
NoWhitespaceAfterScheme,
TabBetweenSchemeAndToken,
MultipleSpacesAfterScheme,
DuplicateHeaderFirstWinsBenign,
QuotedScheme,
TrailingJunkAfterToken,
ControlByteInToken,
}
#[derive(Debug, Clone)]
pub struct AuthSmuggleProbe {
pub variant: AuthHeaderVariant,
pub header_lines: Vec<(String, String)>,
pub description: String,
pub canary: Canary,
}
impl AuthSmuggleProbe {
fn finalise(
variant: AuthHeaderVariant,
mut header_lines: Vec<(String, String)>,
description: String,
) -> Self {
for (_, v) in header_lines.iter_mut() {
if v.len() > MAX_AUTH_HEADER_BYTES {
let cut = crate::floor_char_boundary(v, MAX_AUTH_HEADER_BYTES);
v.truncate(cut);
}
}
Self {
variant,
header_lines,
description,
canary: Canary::generate(),
}
}
#[must_use]
pub fn lowercase_scheme(header_name: &str, scheme: &str, token: &str) -> Self {
let value = format!("{} {}", scheme.to_lowercase(), sanitise_token(token));
Self::finalise(
AuthHeaderVariant::LowercaseScheme,
vec![(header_name.to_string(), value)],
format!(
"Lowercase auth scheme {:?} — RFC 7235 §2.1 case-insensitive but some WAFs match literal",
scheme.to_lowercase()
),
)
}
#[must_use]
pub fn no_whitespace_after_scheme(header_name: &str, scheme: &str, token: &str) -> Self {
let value = format!("{}{}", scheme, sanitise_token(token));
Self::finalise(
AuthHeaderVariant::NoWhitespaceAfterScheme,
vec![(header_name.to_string(), value)],
"No SP between scheme and token — RFC 7235 §2.1 violation, lenient parsers join".into(),
)
}
#[must_use]
pub fn tab_between_scheme_and_token(header_name: &str, scheme: &str, token: &str) -> Self {
let value = format!("{}\t{}", scheme, sanitise_token(token));
Self::finalise(
AuthHeaderVariant::TabBetweenSchemeAndToken,
vec![(header_name.to_string(), value)],
"TAB between scheme and token — RFC requires SP, some accept any LWS".into(),
)
}
#[must_use]
pub fn multiple_spaces_after_scheme(header_name: &str, scheme: &str, token: &str) -> Self {
let mut rng = rand::thread_rng();
let n = rng.gen_range(3..=7);
let value = format!("{}{}{}", scheme, " ".repeat(n), sanitise_token(token));
Self::finalise(
AuthHeaderVariant::MultipleSpacesAfterScheme,
vec![(header_name.to_string(), value)],
format!("{n} spaces between scheme and token — boundary stretch of `1*SP`"),
)
}
#[must_use]
pub fn duplicate_header_first_wins_benign(
header_name: &str,
scheme: &str,
benign_token: &str,
smuggle_token: &str,
) -> Self {
let v1 = format!("{} {}", scheme, sanitise_token(benign_token));
let v2 = format!("{} {}", scheme, sanitise_token(smuggle_token));
Self::finalise(
AuthHeaderVariant::DuplicateHeaderFirstWinsBenign,
vec![(header_name.to_string(), v1), (header_name.to_string(), v2)],
"Duplicate Authorization headers — nginx-vs-Apache first/last-wins differential".into(),
)
}
#[must_use]
pub fn quoted_scheme(header_name: &str, scheme: &str, token: &str) -> Self {
let clean_scheme = scheme.replace('"', "");
let value = format!("\"{}\" {}", clean_scheme, sanitise_token(token));
Self::finalise(
AuthHeaderVariant::QuotedScheme,
vec![(header_name.to_string(), value)],
"Quoted scheme — strict RFC rejects, lax parsers strip quotes".into(),
)
}
#[must_use]
pub fn trailing_junk_after_token(
header_name: &str,
scheme: &str,
token: &str,
junk: &str,
) -> Self {
let value = format!(
"{} {} {}",
scheme,
sanitise_token(token),
sanitise_token(junk)
);
Self::finalise(
AuthHeaderVariant::TrailingJunkAfterToken,
vec![(header_name.to_string(), value)],
"Trailing bytes after token — parser stops at SP vs WAF scans whole value".into(),
)
}
#[must_use]
pub fn control_byte_in_token(header_name: &str, scheme: &str, token: &str) -> Self {
let clean = sanitise_token(token);
let ctl = pick_from(CONTROL_BYTE_POOL, b'\t');
let mid = crate::floor_char_boundary(&clean, clean.len() / 2);
let value = format!(
"{} {}{}{}",
scheme,
&clean[..mid],
ctl as char,
&clean[mid..]
);
Self::finalise(
AuthHeaderVariant::ControlByteInToken,
vec![(header_name.to_string(), value)],
format!("Control byte 0x{ctl:02x} inside token — strict reject vs lax strip"),
)
}
}
impl SmuggleProbe for AuthSmuggleProbe {
fn canary(&self) -> &Canary {
&self.canary
}
fn technique(&self) -> String {
let suffix = match self.variant {
AuthHeaderVariant::LowercaseScheme => "lowercase-scheme",
AuthHeaderVariant::NoWhitespaceAfterScheme => "no-whitespace-after-scheme",
AuthHeaderVariant::TabBetweenSchemeAndToken => "tab-between-scheme-and-token",
AuthHeaderVariant::MultipleSpacesAfterScheme => "multiple-spaces-after-scheme",
AuthHeaderVariant::DuplicateHeaderFirstWinsBenign => {
"duplicate-header-first-wins-benign"
}
AuthHeaderVariant::QuotedScheme => "quoted-scheme",
AuthHeaderVariant::TrailingJunkAfterToken => "trailing-junk-after-token",
AuthHeaderVariant::ControlByteInToken => "control-byte-in-token",
};
format!("auth.{suffix}")
}
fn description(&self) -> &str {
&self.description
}
fn artifact(&self) -> SmuggleArtifact {
SmuggleArtifact::Headers(self.header_lines.clone())
}
}
pub(crate) const CONTROL_BYTE_POOL: &[u8] = &[
0x09, 0x0B, 0x0C, 0x1F, 0x7F, ];
fn sanitise_token(s: &str) -> String {
s.chars()
.filter(|&c| c != '\r' && c != '\n' && c != '\0')
.collect()
}
#[must_use]
pub fn all_variants(header_name: &str, scheme: &str, token: &str) -> Vec<AuthSmuggleProbe> {
vec![
AuthSmuggleProbe::lowercase_scheme(header_name, scheme, token),
AuthSmuggleProbe::no_whitespace_after_scheme(header_name, scheme, token),
AuthSmuggleProbe::tab_between_scheme_and_token(header_name, scheme, token),
AuthSmuggleProbe::multiple_spaces_after_scheme(header_name, scheme, token),
AuthSmuggleProbe::duplicate_header_first_wins_benign(
header_name,
scheme,
"benign-token-aaaa",
token,
),
AuthSmuggleProbe::quoted_scheme(header_name, scheme, token),
AuthSmuggleProbe::trailing_junk_after_token(header_name, scheme, token, "junk-tail"),
AuthSmuggleProbe::control_byte_in_token(header_name, scheme, token),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn sweep_emits_eight_distinct_variants() {
let v = all_variants("Authorization", "Bearer", "eyJhbGciOiJ");
assert_eq!(v.len(), 8);
let kinds: HashSet<_> = v.iter().map(|p| p.variant).collect();
assert_eq!(kinds.len(), 8);
}
#[test]
fn lowercase_scheme_probe_actually_lowercases_the_scheme() {
let p = AuthSmuggleProbe::lowercase_scheme("Authorization", "Bearer", "X");
let (_, v) = &p.header_lines[0];
assert!(v.starts_with("bearer "), "expected lowercase scheme: {v:?}");
assert!(
!v.starts_with("Bearer "),
"must NOT preserve original case: {v:?}"
);
}
#[test]
fn no_whitespace_probe_has_no_sp_between_scheme_and_token() {
let p = AuthSmuggleProbe::no_whitespace_after_scheme("Authorization", "Bearer", "Token");
let (_, v) = &p.header_lines[0];
assert!(
!v.contains(' '),
"no-whitespace probe must contain zero SPs, got: {v:?}"
);
assert!(v.starts_with("BearerToken"));
}
#[test]
fn tab_probe_uses_tab_not_space() {
let p = AuthSmuggleProbe::tab_between_scheme_and_token("Authorization", "Bearer", "T");
let (_, v) = &p.header_lines[0];
assert!(v.contains('\t'), "expected TAB in header value: {v:?}");
assert!(
!v.contains(' '),
"TAB probe must not also carry a space (would defeat the test)"
);
}
#[test]
fn multiple_spaces_probe_has_three_to_seven_spaces() {
let p = AuthSmuggleProbe::multiple_spaces_after_scheme("Authorization", "Bearer", "T");
let (_, v) = &p.header_lines[0];
let after_bearer = v.trim_start_matches("Bearer");
let space_count = after_bearer.chars().take_while(|&c| c == ' ').count();
assert!(
(3..=7).contains(&space_count),
"expected 3..=7 spaces, got {space_count}"
);
}
#[test]
fn duplicate_header_probe_emits_two_header_lines_same_name() {
let p = AuthSmuggleProbe::duplicate_header_first_wins_benign(
"Authorization",
"Bearer",
"benign",
"smuggle",
);
assert_eq!(p.header_lines.len(), 2);
assert_eq!(p.header_lines[0].0, "Authorization");
assert_eq!(p.header_lines[1].0, "Authorization");
assert!(p.header_lines[0].1.contains("benign"));
assert!(p.header_lines[1].1.contains("smuggle"));
}
#[test]
fn quoted_scheme_probe_wraps_scheme_in_double_quotes() {
let p = AuthSmuggleProbe::quoted_scheme("Authorization", "Bearer", "T");
let (_, v) = &p.header_lines[0];
assert!(v.starts_with("\"Bearer\""), "got: {v:?}");
}
#[test]
fn quoted_scheme_strips_inner_quotes_from_scheme() {
let p = AuthSmuggleProbe::quoted_scheme("Authorization", "Be\"a\"rer", "T");
let (_, v) = &p.header_lines[0];
assert_eq!(
v.matches('"').count(),
2,
"expected exactly 2 quotes (the wrappers), got: {v:?}"
);
}
#[test]
fn trailing_junk_probe_appends_extra_bytes_after_token() {
let p =
AuthSmuggleProbe::trailing_junk_after_token("Authorization", "Bearer", "TOK", "EXTRA");
let (_, v) = &p.header_lines[0];
let parts: Vec<&str> = v.splitn(3, ' ').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "Bearer");
assert_eq!(parts[1], "TOK");
assert_eq!(parts[2], "EXTRA");
}
#[test]
fn ctl_probe_injects_a_byte_from_the_pool() {
let p = AuthSmuggleProbe::control_byte_in_token("Authorization", "Bearer", "ABCDEF");
let (_, v) = &p.header_lines[0];
let bytes = v.as_bytes();
assert!(
bytes.iter().any(|b| CONTROL_BYTE_POOL.contains(b)),
"no CTL byte found in header: {v:?}"
);
}
#[test]
fn sanitise_strips_cr_lf_nul_from_token() {
let p = AuthSmuggleProbe::lowercase_scheme("Authorization", "Bearer", "to\rke\nn\0X");
let (_, v) = &p.header_lines[0];
assert!(!v.contains('\r'));
assert!(!v.contains('\n'));
assert!(!v.contains('\0'));
}
#[test]
fn every_probe_carries_a_distinct_canary() {
let a = AuthSmuggleProbe::lowercase_scheme("Authorization", "Bearer", "x");
let b = AuthSmuggleProbe::lowercase_scheme("Authorization", "Bearer", "x");
assert_ne!(a.canary.token, b.canary.token);
assert_eq!(a.canary.token.len(), 16);
}
#[test]
fn header_value_capped_at_max() {
let huge = "x".repeat(MAX_AUTH_HEADER_BYTES * 4);
let p = AuthSmuggleProbe::lowercase_scheme("Authorization", "Bearer", &huge);
let (_, v) = &p.header_lines[0];
assert!(
v.len() <= MAX_AUTH_HEADER_BYTES,
"header value exceeded cap: {}",
v.len()
);
}
#[test]
fn proxy_authorization_header_name_also_supported() {
let p = AuthSmuggleProbe::lowercase_scheme("Proxy-Authorization", "Bearer", "T");
assert_eq!(p.header_lines[0].0, "Proxy-Authorization");
}
#[test]
fn empty_inputs_do_not_panic_in_any_builder() {
let _ = AuthSmuggleProbe::lowercase_scheme("Authorization", "", "");
let _ = AuthSmuggleProbe::no_whitespace_after_scheme("Authorization", "", "");
let _ = AuthSmuggleProbe::tab_between_scheme_and_token("Authorization", "", "");
let _ = AuthSmuggleProbe::multiple_spaces_after_scheme("Authorization", "", "");
let _ = AuthSmuggleProbe::duplicate_header_first_wins_benign("Authorization", "", "", "");
let _ = AuthSmuggleProbe::quoted_scheme("Authorization", "", "");
let _ = AuthSmuggleProbe::trailing_junk_after_token("Authorization", "", "", "");
let _ = AuthSmuggleProbe::control_byte_in_token("Authorization", "", "");
}
#[test]
fn control_byte_in_token_multibyte_does_not_panic() {
for tok in ["éa", "aé", "日本語", "🦀x", "Bearer-café-日"] {
let p = AuthSmuggleProbe::control_byte_in_token("Authorization", "Bearer", tok);
assert!(
p.header_lines.iter().any(|(_, v)| !v.is_empty()),
"control-byte-in-token must not panic on multibyte token {tok:?}"
);
}
}
}