use rand::Rng;
use wafrift_types::canary::Canary;
use wafrift_types::pick::pick_from;
use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
pub const MAX_COOKIE_HEADER_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CookieSmuggleVariant {
SecurePrefixWithoutHttps,
HostPrefixWithDomain,
DuplicateNameLastWins,
QuotedSemicolonValue,
EmptyNamePair,
ControlByteInValue,
WhitespaceAroundEquals,
}
#[derive(Debug, Clone)]
pub struct CookieSmuggleProbe {
pub variant: CookieSmuggleVariant,
pub header_value: String,
pub description: String,
pub canary: Canary,
}
impl CookieSmuggleProbe {
fn finalise(
variant: CookieSmuggleVariant,
mut header_value: String,
description: String,
) -> Self {
if header_value.len() > MAX_COOKIE_HEADER_BYTES {
let cut = crate::floor_char_boundary(&header_value, MAX_COOKIE_HEADER_BYTES);
header_value.truncate(cut);
}
Self {
variant,
header_value,
description,
canary: Canary::generate(),
}
}
#[must_use]
pub fn secure_prefix_without_https(name: &str, value: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_value = sanitise_cookie_token(value);
let header = format!("__Secure-{safe_name}={safe_value}");
Self::finalise(
CookieSmuggleVariant::SecurePrefixWithoutHttps,
header,
"__Secure- prefix on a Cookie sent over plain HTTP — RFC 6265bis §4.1.3.1 violation"
.into(),
)
}
#[must_use]
pub fn host_prefix_with_domain(name: &str, value: &str, domain: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_value = sanitise_cookie_token(value);
let safe_domain = sanitise_cookie_token(domain);
let header = format!("__Host-{safe_name}={safe_value}; Domain={safe_domain}");
Self::finalise(
CookieSmuggleVariant::HostPrefixWithDomain,
header,
"__Host- prefix with Domain attribute — RFC 6265bis §4.1.3.2 violation".into(),
)
}
#[must_use]
pub fn duplicate_name_last_wins(name: &str, benign: &str, smuggle: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_benign = sanitise_cookie_token(benign);
let safe_smuggle = sanitise_cookie_token(smuggle);
let header = format!("{safe_name}={safe_benign}; {safe_name}={safe_smuggle}");
Self::finalise(
CookieSmuggleVariant::DuplicateNameLastWins,
header,
"Duplicate-name cookie pair — first/last resolution differential".into(),
)
}
#[must_use]
pub fn quoted_semicolon_value(name: &str, inner_payload: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_inner = inner_payload.replace(['"', '\r', '\n'], "");
let header = format!("{safe_name}=\"{safe_inner}\"");
Self::finalise(
CookieSmuggleVariant::QuotedSemicolonValue,
header,
"Quoted-string value with embedded ';' — RFC 6265 vs 6265bis differential".into(),
)
}
#[must_use]
pub fn empty_name_pair(value: &str) -> Self {
let safe_value = sanitise_cookie_token(value);
let header = format!("={safe_value}");
Self::finalise(
CookieSmuggleVariant::EmptyNamePair,
header,
"Empty-name cookie pair — RFC violation, lax parsers accept under empty key".into(),
)
}
#[must_use]
pub fn control_byte_in_value(name: &str, value: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_value = sanitise_cookie_token(value);
let ctl = pick_from(CONTROL_BYTE_POOL, b'\t');
let mid = crate::floor_char_boundary(&safe_value, safe_value.len() / 2);
let header = format!(
"{safe_name}={}{}{}",
&safe_value[..mid],
ctl as char,
&safe_value[mid..]
);
Self::finalise(
CookieSmuggleVariant::ControlByteInValue,
header,
format!(
"Control byte 0x{ctl:02x} inside cookie value — strict CTL-reject vs lax-strip"
),
)
}
#[must_use]
pub fn whitespace_around_equals(name: &str, value: &str) -> Self {
let safe_name = sanitise_cookie_token(name);
let safe_value = sanitise_cookie_token(value);
let mut rng = rand::thread_rng();
let left_n = rng.gen_range(1..=3);
let right_n = rng.gen_range(1..=3);
let header = format!(
"{safe_name}{}={}{safe_value}",
" ".repeat(left_n),
" ".repeat(right_n)
);
Self::finalise(
CookieSmuggleVariant::WhitespaceAroundEquals,
header,
"Whitespace around '=' — trim vs preserve differential".into(),
)
}
}
impl SmuggleProbe for CookieSmuggleProbe {
fn canary(&self) -> &Canary {
&self.canary
}
fn technique(&self) -> String {
let suffix = match self.variant {
CookieSmuggleVariant::SecurePrefixWithoutHttps => "secure-prefix-without-https",
CookieSmuggleVariant::HostPrefixWithDomain => "host-prefix-with-domain",
CookieSmuggleVariant::DuplicateNameLastWins => "duplicate-name-last-wins",
CookieSmuggleVariant::QuotedSemicolonValue => "quoted-semicolon-value",
CookieSmuggleVariant::EmptyNamePair => "empty-name-pair",
CookieSmuggleVariant::ControlByteInValue => "control-byte-in-value",
CookieSmuggleVariant::WhitespaceAroundEquals => "whitespace-around-equals",
};
format!("cookie.{suffix}")
}
fn description(&self) -> &str {
&self.description
}
fn artifact(&self) -> SmuggleArtifact {
SmuggleArtifact::Headers(vec![("Cookie".into(), self.header_value.clone())])
}
}
pub(crate) const CONTROL_BYTE_POOL: &[u8] = &[
0x09, 0x0B, 0x0C, 0x1F, 0x7F, ];
fn sanitise_cookie_token(s: &str) -> String {
s.chars()
.filter(|&c| c != '\r' && c != '\n' && c != '\0')
.collect()
}
#[must_use]
pub fn all_variants(name: &str, value: &str) -> Vec<CookieSmuggleProbe> {
vec![
CookieSmuggleProbe::secure_prefix_without_https(name, value),
CookieSmuggleProbe::host_prefix_with_domain(name, value, "evil.example.com"),
CookieSmuggleProbe::duplicate_name_last_wins(name, "benign-token", value),
CookieSmuggleProbe::quoted_semicolon_value(name, &format!("{value}; admin=true")),
CookieSmuggleProbe::empty_name_pair(value),
CookieSmuggleProbe::control_byte_in_value(name, value),
CookieSmuggleProbe::whitespace_around_equals(name, value),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn sweep_emits_seven_distinct_variants() {
let v = all_variants("session", "abc123");
assert_eq!(v.len(), 7);
let kinds: HashSet<_> = v.iter().map(|p| p.variant).collect();
assert_eq!(kinds.len(), 7);
}
#[test]
fn secure_prefix_probe_starts_with_underscore_underscore_secure() {
let p = CookieSmuggleProbe::secure_prefix_without_https("auth", "token");
assert!(
p.header_value.starts_with("__Secure-"),
"expected __Secure- prefix, got: {:?}",
p.header_value
);
assert!(p.header_value.contains("auth=token"));
}
#[test]
fn host_prefix_probe_carries_forbidden_domain_attribute() {
let p = CookieSmuggleProbe::host_prefix_with_domain("sess", "x", "attacker.tld");
assert!(p.header_value.starts_with("__Host-"));
assert!(p.header_value.contains("Domain=attacker.tld"));
}
#[test]
fn duplicate_name_probe_emits_both_pairs_in_order() {
let p = CookieSmuggleProbe::duplicate_name_last_wins("role", "guest", "admin");
let first = p.header_value.find("role=guest").expect("benign present");
let second = p.header_value.find("role=admin").expect("smuggle present");
assert!(
first < second,
"benign pair must precede smuggle pair on the wire"
);
}
#[test]
fn quoted_semicolon_probe_double_quotes_the_value() {
let p = CookieSmuggleProbe::quoted_semicolon_value("sess", "a;b=c");
assert!(p.header_value.contains("=\""));
assert!(p.header_value.ends_with('"'));
}
#[test]
fn quoted_semicolon_probe_strips_inner_quotes() {
let p = CookieSmuggleProbe::quoted_semicolon_value("sess", "a\"b\"c");
assert_eq!(
p.header_value.matches('"').count(),
2,
"exactly two quotes (the wrappers), got: {:?}",
p.header_value
);
}
#[test]
fn empty_name_probe_starts_with_equals() {
let p = CookieSmuggleProbe::empty_name_pair("payload");
assert!(p.header_value.starts_with('='));
assert!(p.header_value.contains("payload"));
}
#[test]
fn control_byte_probe_injects_a_ctl_from_the_pool() {
let p = CookieSmuggleProbe::control_byte_in_value("name", "abcdef");
let bytes = p.header_value.as_bytes();
assert!(
bytes.iter().any(|b| CONTROL_BYTE_POOL.contains(b)),
"no CTL byte found in header value: {:?}",
p.header_value
);
}
#[test]
fn whitespace_probe_inserts_spaces_around_equals() {
let p = CookieSmuggleProbe::whitespace_around_equals("name", "value");
assert!(
!p.header_value.contains("name=value"),
"tight name=value defeats the probe: {:?}",
p.header_value
);
assert!(
p.header_value.contains(" =") || p.header_value.contains("= "),
"expected whitespace around '=', got: {:?}",
p.header_value
);
}
#[test]
fn sanitise_strips_cr_lf_nul_unconditionally() {
let p = CookieSmuggleProbe::secure_prefix_without_https("na\rme\n", "val\0ue");
assert!(!p.header_value.contains('\r'));
assert!(!p.header_value.contains('\n'));
assert!(!p.header_value.contains('\0'));
}
#[test]
fn every_probe_carries_a_distinct_canary() {
let a = CookieSmuggleProbe::empty_name_pair("x");
let b = CookieSmuggleProbe::empty_name_pair("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_COOKIE_HEADER_BYTES * 4);
let p = CookieSmuggleProbe::secure_prefix_without_https("name", &huge);
assert!(
p.header_value.len() <= MAX_COOKIE_HEADER_BYTES,
"header value exceeded cap: {}",
p.header_value.len()
);
}
#[test]
fn empty_inputs_do_not_panic_in_any_builder() {
let _ = CookieSmuggleProbe::secure_prefix_without_https("", "");
let _ = CookieSmuggleProbe::host_prefix_with_domain("", "", "");
let _ = CookieSmuggleProbe::duplicate_name_last_wins("", "", "");
let _ = CookieSmuggleProbe::quoted_semicolon_value("", "");
let _ = CookieSmuggleProbe::empty_name_pair("");
let _ = CookieSmuggleProbe::control_byte_in_value("", "");
let _ = CookieSmuggleProbe::whitespace_around_equals("", "");
}
#[test]
fn control_byte_pool_is_non_empty_and_all_ctl_range() {
assert!(!CONTROL_BYTE_POOL.is_empty());
for &b in CONTROL_BYTE_POOL {
assert!(
b < 0x20 || b == 0x7F,
"byte 0x{b:02x} is not a CTL per RFC 5234"
);
}
}
#[test]
fn control_byte_in_value_multibyte_does_not_panic() {
for v in ["éa", "aé", "日本語", "🦀x", "x🦀", "café-au-lait-日"] {
let p = CookieSmuggleProbe::control_byte_in_value("sess", v);
assert!(
p.header_value.starts_with("sess="),
"control-byte probe must not panic on multibyte value {v:?}; got {:?}",
p.header_value
);
}
}
#[test]
fn all_variants_multibyte_value_no_panic() {
let probes = all_variants("名前", "値é日🦀");
assert_eq!(probes.len(), 7, "sweep must emit one probe per variant");
for p in &probes {
assert!(!p.header_value.is_empty());
}
}
#[test]
fn finalise_truncates_oversize_multibyte_at_char_boundary() {
let big = "日".repeat(3000); let p = CookieSmuggleProbe::secure_prefix_without_https("nn", &big);
assert!(
p.header_value.len() <= MAX_COOKIE_HEADER_BYTES,
"truncated value must be within the cap: {} > {}",
p.header_value.len(),
MAX_COOKIE_HEADER_BYTES
);
}
}