Skip to main content

termivar_scanner/payload_strategies/
xss_attribute_boundary_query_pair.rs

1//! Non-executing quote-aware HTML attribute-boundary probe pair.
2//!
3//! The seed is closed metadata only: one attribute family code, one exact
4//! source quote mode, and one scanner-owned lowercase hexadecimal identity.
5//! Target values never participate in payload derivation.
6
7use crate::payload_strategy::{
8    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
9    PayloadStrategyRef, PayloadVariantRole,
10};
11
12pub const XSS_ATTRIBUTE_BOUNDARY_QUERY_PAIR_ID: &str =
13    "web.review.xss.attribute-boundary-query-pair";
14pub const XSS_ATTRIBUTE_BOUNDARY_QUERY_PAIR_REVISION: u32 = 1;
15
16#[derive(Debug, Clone)]
17pub struct XssAttributeBoundaryQueryPairStrategy {
18    reference: PayloadStrategyRef,
19}
20
21impl XssAttributeBoundaryQueryPairStrategy {
22    pub fn new() -> Self {
23        Self {
24            reference: PayloadStrategyRef::new(
25                XSS_ATTRIBUTE_BOUNDARY_QUERY_PAIR_ID,
26                XSS_ATTRIBUTE_BOUNDARY_QUERY_PAIR_REVISION,
27            )
28            .expect("the XSS attribute-boundary strategy identity is static and valid"),
29        }
30    }
31
32    fn derive_value(seed: &[u8], role: PayloadVariantRole) -> Option<String> {
33        let seed = std::str::from_utf8(seed).ok()?;
34        let mut parts = seed.split(':');
35        let family = parts.next()?;
36        let quote_mode = parts.next()?;
37        let identity = parts.next()?;
38        if parts.next().is_some()
39            || !matches!(family, "attribute" | "uri" | "handler")
40            || !matches!(quote_mode, "double-quoted" | "single-quoted" | "unquoted")
41            || identity.len() != 32
42            || !identity
43                .bytes()
44                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
45        {
46            return None;
47        }
48        if role == PayloadVariantRole::Control {
49            return Some(format!("venom-xss-attribute-control-{identity}-end"));
50        }
51        match quote_mode {
52            "double-quoted" => Some(format!(
53                "\" data-venom-xss-boundary-token=\"{identity}\" data-venom-xss-tail-token=\"{identity}"
54            )),
55            "single-quoted" => Some(format!(
56                "' data-venom-xss-boundary-token='{identity}' data-venom-xss-tail-token='{identity}"
57            )),
58            "unquoted" => Some(format!(
59                "venom-xss-inert-{identity} data-venom-xss-boundary-token={identity} data-venom-xss-tail-token={identity}"
60            )),
61            _ => None,
62        }
63    }
64}
65
66impl Default for XssAttributeBoundaryQueryPairStrategy {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl PayloadStrategy for XssAttributeBoundaryQueryPairStrategy {
73    fn strategy_ref(&self) -> &PayloadStrategyRef {
74        &self.reference
75    }
76
77    fn derive_one(
78        &self,
79        role: PayloadVariantRole,
80        seed: &PayloadSeed,
81        limits: PayloadStrategyLimits,
82    ) -> Result<PayloadArtifact, PayloadStrategyError> {
83        let value = Self::derive_value(seed.as_bytes(), role)
84            .ok_or(PayloadStrategyError::DerivationFailed)?;
85        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    const IDENTITY: &str = "0123456789abcdef0123456789abcdef";
94
95    fn limits() -> PayloadStrategyLimits {
96        PayloadStrategyLimits::new(256, 256).unwrap()
97    }
98
99    fn derive(family: &str, quote: &str, role: PayloadVariantRole) -> String {
100        let strategy = XssAttributeBoundaryQueryPairStrategy::new();
101        let seed = PayloadSeed::new(
102            format!("{family}:{quote}:{IDENTITY}").into_bytes(),
103            limits(),
104        )
105        .unwrap();
106        String::from_utf8(
107            strategy
108                .derive_one(role, &seed, limits())
109                .unwrap()
110                .as_bytes()
111                .to_vec(),
112        )
113        .unwrap()
114    }
115
116    #[test]
117    fn quote_variants_have_exact_inert_bounded_wire_shapes() {
118        assert_eq!(
119            derive("attribute", "double-quoted", PayloadVariantRole::Candidate),
120            format!(
121                "\" data-venom-xss-boundary-token=\"{IDENTITY}\" data-venom-xss-tail-token=\"{IDENTITY}"
122            )
123        );
124        assert_eq!(
125            derive("uri", "single-quoted", PayloadVariantRole::Candidate),
126            format!(
127                "' data-venom-xss-boundary-token='{IDENTITY}' data-venom-xss-tail-token='{IDENTITY}"
128            )
129        );
130        assert_eq!(
131            derive("handler", "unquoted", PayloadVariantRole::Candidate),
132            format!(
133                "venom-xss-inert-{IDENTITY} data-venom-xss-boundary-token={IDENTITY} data-venom-xss-tail-token={IDENTITY}"
134            )
135        );
136
137        for family in ["attribute", "uri", "handler"] {
138            for quote in ["double-quoted", "single-quoted", "unquoted"] {
139                let control = derive(family, quote, PayloadVariantRole::Control);
140                let candidate = derive(family, quote, PayloadVariantRole::Candidate);
141                assert_ne!(control, candidate);
142                assert!(candidate.len() <= 256);
143                for forbidden in [
144                    "alert",
145                    "prompt",
146                    "confirm",
147                    "javascript:",
148                    "data:",
149                    "fetch",
150                    "document.",
151                    "window.",
152                    "http://",
153                    "https://",
154                    "//",
155                ] {
156                    assert!(!candidate.contains(forbidden), "{forbidden} in {candidate}");
157                }
158            }
159        }
160    }
161
162    #[test]
163    fn unknown_family_quote_and_noncanonical_identity_fail_closed() {
164        let strategy = XssAttributeBoundaryQueryPairStrategy::new();
165        for value in [
166            format!("unknown:double-quoted:{IDENTITY}"),
167            format!("attribute:unknown:{IDENTITY}"),
168            "attribute:double-quoted:short".to_owned(),
169            "attribute:double-quoted:0123456789ABCDEF0123456789ABCDEF".to_owned(),
170            format!("attribute:double-quoted:{IDENTITY}:extra"),
171        ] {
172            let seed = PayloadSeed::new(value.into_bytes(), limits()).unwrap();
173            for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
174                assert!(matches!(
175                    strategy.derive_one(role, &seed, limits()),
176                    Err(PayloadStrategyError::DerivationFailed)
177                ));
178            }
179        }
180    }
181}