Skip to main content

termivar_scanner/payload_strategies/
xss_structural_query_pair.rs

1//! Non-executing context-specific structural XSS probe pair.
2//!
3//! The seed contains only a closed family code and a scanner-owned lowercase
4//! hexadecimal identity. No target value participates in derivation.
5
6use crate::payload_strategy::{
7    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
8    PayloadStrategyRef, PayloadVariantRole,
9};
10
11pub const XSS_STRUCTURAL_QUERY_PAIR_ID: &str = "web.review.xss.structural-query-pair";
12pub const XSS_STRUCTURAL_QUERY_PAIR_REVISION: u32 = 1;
13
14#[derive(Debug, Clone)]
15pub struct XssStructuralQueryPairStrategy {
16    reference: PayloadStrategyRef,
17}
18
19impl XssStructuralQueryPairStrategy {
20    pub fn new() -> Self {
21        Self {
22            reference: PayloadStrategyRef::new(
23                XSS_STRUCTURAL_QUERY_PAIR_ID,
24                XSS_STRUCTURAL_QUERY_PAIR_REVISION,
25            )
26            .expect("the XSS structural strategy identity is static and valid"),
27        }
28    }
29
30    fn derive_value(seed: &[u8], role: PayloadVariantRole) -> Option<String> {
31        let seed = std::str::from_utf8(seed).ok()?;
32        let (family, identity) = seed.split_once(':')?;
33        if identity.len() != 32
34            || !identity
35                .bytes()
36                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
37        {
38            return None;
39        }
40        if role == PayloadVariantRole::Control {
41            return Some(format!("venom-xss-control-{identity}-end"));
42        }
43        match family {
44            "html" => Some(format!(
45                "<span data-venom-xss-boundary-token=\"{identity}\"></span>"
46            )),
47            "uri" => Some(format!(
48                "venom-xss-{identity}/segment?probe={identity}#boundary"
49            )),
50            "handler" => Some(format!("/*venom-xss-handler-{identity}*/")),
51            "script" => Some(format!("/*venom-xss-script-{identity}*/")),
52            _ => None,
53        }
54    }
55}
56
57impl Default for XssStructuralQueryPairStrategy {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl PayloadStrategy for XssStructuralQueryPairStrategy {
64    fn strategy_ref(&self) -> &PayloadStrategyRef {
65        &self.reference
66    }
67
68    fn derive_one(
69        &self,
70        role: PayloadVariantRole,
71        seed: &PayloadSeed,
72        limits: PayloadStrategyLimits,
73    ) -> Result<PayloadArtifact, PayloadStrategyError> {
74        let value = Self::derive_value(seed.as_bytes(), role)
75            .ok_or(PayloadStrategyError::DerivationFailed)?;
76        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn limits() -> PayloadStrategyLimits {
85        PayloadStrategyLimits::new(256, 256).unwrap()
86    }
87
88    #[test]
89    fn closed_families_create_distinct_bounded_non_networking_candidates() {
90        let strategy = XssStructuralQueryPairStrategy::new();
91        for family in ["html", "uri", "handler", "script"] {
92            let seed = PayloadSeed::new(
93                format!("{family}:0123456789abcdef0123456789abcdef").into_bytes(),
94                limits(),
95            )
96            .unwrap();
97            let control = strategy
98                .derive_one(PayloadVariantRole::Control, &seed, limits())
99                .unwrap();
100            let candidate = strategy
101                .derive_one(PayloadVariantRole::Candidate, &seed, limits())
102                .unwrap();
103            assert_ne!(control.as_bytes(), candidate.as_bytes());
104            assert!(candidate.as_bytes().len() <= 160);
105            let text = std::str::from_utf8(candidate.as_bytes()).unwrap();
106            for forbidden in [
107                "alert(",
108                "javascript:",
109                "data:",
110                "http://",
111                "https://",
112                "//",
113                "fetch(",
114                "document.",
115                "window.",
116            ] {
117                assert!(
118                    !text.contains(forbidden),
119                    "unsafe token {forbidden} in {text}"
120                );
121            }
122        }
123    }
124
125    #[test]
126    fn unknown_family_and_noncanonical_identity_fail_closed() {
127        let strategy = XssStructuralQueryPairStrategy::new();
128        for seed in [
129            "unknown:0123456789abcdef0123456789abcdef",
130            "html:short",
131            "html:0123456789ABCDEF0123456789ABCDEF",
132        ] {
133            let seed = PayloadSeed::new(seed.as_bytes().to_vec(), limits()).unwrap();
134            assert!(matches!(
135                strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
136                Err(PayloadStrategyError::DerivationFailed)
137            ));
138        }
139    }
140}