use wafrift_types::canary::Canary;
use wafrift_types::pick::pick_from;
use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HostSmuggleTechnique {
DuplicateHostHeaderLastWins,
HostWithDefaultPort,
HostWithUserinfo,
HostWithTrailingDot,
HostWithCaseMix,
HostWithUnderscoreSubdomain,
HostWithFullwidthDot,
HostWithEmbeddedTab,
}
impl HostSmuggleTechnique {
#[must_use]
pub fn technique_name(&self) -> &'static str {
match self {
Self::DuplicateHostHeaderLastWins => "host.duplicate-header-last-wins",
Self::HostWithDefaultPort => "host.with-default-port",
Self::HostWithUserinfo => "host.with-userinfo",
Self::HostWithTrailingDot => "host.with-trailing-dot",
Self::HostWithCaseMix => "host.with-case-mix",
Self::HostWithUnderscoreSubdomain => "host.with-underscore-subdomain",
Self::HostWithFullwidthDot => "host.with-fullwidth-dot",
Self::HostWithEmbeddedTab => "host.with-embedded-tab",
}
}
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::DuplicateHostHeaderLastWins => {
"Duplicate Host header — first-vs-last resolution differential"
}
Self::HostWithDefaultPort => {
"Host with explicit default port — literal-match WAF rule bypass"
}
Self::HostWithUserinfo => {
"Host with userinfo prefix — RFC 3986 strip-vs-reject differential"
}
Self::HostWithTrailingDot => {
"Trailing-dot FQDN — DNS-equivalent, byte-different differential"
}
Self::HostWithCaseMix => "Mixed-case host — case-sensitivity differential",
Self::HostWithUnderscoreSubdomain => {
"Underscore in subdomain — RFC 3986 forbidden, accepted by lenient parsers"
}
Self::HostWithFullwidthDot => "U+FF0E fullwidth dot — NFKC normalization differential",
Self::HostWithEmbeddedTab => {
"Embedded TAB in host value — strip-vs-reject differential"
}
}
}
}
const BENIGN_HOST_POOL: &[&str] = &["www.example.com", "cdn.example.org", "static.example.net"];
#[derive(Debug, Clone)]
pub struct HostSmuggleProbe {
pub canary: Canary,
pub technique: HostSmuggleTechnique,
pub headers: Vec<(String, String)>,
}
fn mix_case(host: &str) -> String {
host.chars()
.enumerate()
.map(|(i, c)| {
if i % 2 == 0 {
c.to_ascii_uppercase()
} else {
c.to_ascii_lowercase()
}
})
.collect()
}
impl HostSmuggleProbe {
#[must_use]
pub fn new(technique: HostSmuggleTechnique, target: &str) -> Self {
let headers = match technique {
HostSmuggleTechnique::DuplicateHostHeaderLastWins => {
let benign = pick_from(BENIGN_HOST_POOL, "www.example.com");
vec![
("Host".to_string(), benign.to_string()),
("Host".to_string(), target.to_string()),
]
}
HostSmuggleTechnique::HostWithDefaultPort => {
vec![("Host".to_string(), format!("{target}:443"))]
}
HostSmuggleTechnique::HostWithUserinfo => {
vec![("Host".to_string(), format!("wafrift@{target}"))]
}
HostSmuggleTechnique::HostWithTrailingDot => {
vec![("Host".to_string(), format!("{target}."))]
}
HostSmuggleTechnique::HostWithCaseMix => {
vec![("Host".to_string(), mix_case(target))]
}
HostSmuggleTechnique::HostWithUnderscoreSubdomain => {
vec![("Host".to_string(), format!("_smuggle.{target}"))]
}
HostSmuggleTechnique::HostWithFullwidthDot => {
let swapped = target.replace('.', "\u{FF0E}");
vec![("Host".to_string(), swapped)]
}
HostSmuggleTechnique::HostWithEmbeddedTab => {
vec![("Host".to_string(), format!("{target}\twafrift"))]
}
};
Self {
canary: Canary::generate(),
technique,
headers,
}
}
}
impl SmuggleProbe for HostSmuggleProbe {
fn canary(&self) -> &Canary {
&self.canary
}
fn technique(&self) -> String {
self.technique.technique_name().to_string()
}
fn description(&self) -> &str {
self.technique.description()
}
fn artifact(&self) -> SmuggleArtifact {
SmuggleArtifact::Headers(self.headers.clone())
}
}
#[must_use]
pub fn all_variants(target: &str) -> Vec<HostSmuggleProbe> {
use HostSmuggleTechnique::*;
[
DuplicateHostHeaderLastWins,
HostWithDefaultPort,
HostWithUserinfo,
HostWithTrailingDot,
HostWithCaseMix,
HostWithUnderscoreSubdomain,
HostWithFullwidthDot,
HostWithEmbeddedTab,
]
.iter()
.map(|t| HostSmuggleProbe::new(*t, target))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
const TARGET: &str = "admin.example.com";
#[test]
fn all_variants_emits_one_per_technique() {
assert_eq!(all_variants(TARGET).len(), 8);
}
#[test]
fn every_probe_uses_host_family_namespace() {
for p in all_variants(TARGET) {
assert!(p.technique().starts_with("host."), "got {}", p.technique());
}
}
#[test]
fn every_probe_emits_at_least_one_host_header_pair() {
for p in all_variants(TARGET) {
match p.artifact() {
SmuggleArtifact::Headers(hs) => {
assert!(!hs.is_empty());
for (name, _) in &hs {
assert_eq!(name, "Host", "all pairs must be Host");
}
}
other => panic!("expected Headers, got {other:?}"),
}
}
}
#[test]
fn duplicate_header_variant_emits_two_pairs() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::DuplicateHostHeaderLastWins, TARGET);
match p.artifact() {
SmuggleArtifact::Headers(hs) => {
assert_eq!(hs.len(), 2);
assert_eq!(hs[1].1, TARGET);
assert_ne!(hs[0].1, TARGET);
}
_ => panic!("expected Headers"),
}
}
#[test]
fn default_port_variant_appends_443() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithDefaultPort, TARGET);
assert!(p.headers[0].1.ends_with(":443"), "got {:?}", p.headers[0].1);
}
#[test]
fn userinfo_variant_contains_at_sign() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithUserinfo, TARGET);
assert!(p.headers[0].1.contains('@'));
assert!(p.headers[0].1.ends_with(TARGET));
}
#[test]
fn trailing_dot_variant_ends_with_dot() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithTrailingDot, TARGET);
assert!(p.headers[0].1.ends_with('.'));
}
#[test]
fn case_mix_variant_differs_from_target_byte_for_byte() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithCaseMix, TARGET);
let v = &p.headers[0].1;
assert_ne!(v.as_str(), TARGET);
assert_eq!(v.to_lowercase(), TARGET.to_lowercase());
}
#[test]
fn underscore_subdomain_variant_contains_underscore() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithUnderscoreSubdomain, TARGET);
assert!(p.headers[0].1.contains('_'));
}
#[test]
fn fullwidth_dot_variant_uses_ff0e_bytes() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithFullwidthDot, TARGET);
let bytes = p.headers[0].1.as_bytes();
assert!(
bytes.windows(3).any(|w| w == [0xEF, 0xBC, 0x8E]),
"got bytes {bytes:?}"
);
}
#[test]
fn embedded_tab_variant_contains_tab_byte() {
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithEmbeddedTab, TARGET);
assert!(p.headers[0].1.contains('\t'));
}
#[test]
fn canaries_are_unique_per_probe() {
let probes = all_variants(TARGET);
let tokens: HashSet<String> = probes.iter().map(|p| p.canary().token.clone()).collect();
assert_eq!(tokens.len(), probes.len());
}
#[test]
fn descriptions_are_non_empty_and_distinct() {
let probes = all_variants(TARGET);
let descs: HashSet<&str> = probes.iter().map(|p| p.description()).collect();
assert_eq!(descs.len(), probes.len(), "descriptions must be distinct");
for p in &probes {
assert!(!p.description().is_empty());
}
}
#[test]
fn technique_names_are_distinct() {
let probes = all_variants(TARGET);
let techs: HashSet<String> = probes.iter().map(|p| p.technique()).collect();
assert_eq!(techs.len(), probes.len());
}
#[test]
fn custom_target_appears_in_artifact() {
let custom = "internal-admin.example.org";
let p = HostSmuggleProbe::new(HostSmuggleTechnique::HostWithTrailingDot, custom);
assert!(p.headers[0].1.contains(custom));
}
}