Skip to main content

termivar_scanner/payload_strategies/
xss_javascript_lexical_boundary_query_pair.rs

1//! Non-executing JavaScript lexical-boundary probe pair.
2//!
3//! The seed is closed metadata only: one supported JavaScript source-context
4//! code and one scanner-owned canonical lowercase hexadecimal identity. Target
5//! values and surrounding script source never participate in derivation.
6
7use crate::payload_strategy::{
8    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
9    PayloadStrategyRef, PayloadVariantRole,
10};
11use std::fmt;
12
13pub const XSS_JAVASCRIPT_LEXICAL_BOUNDARY_QUERY_PAIR_ID: &str =
14    "web.review.xss.javascript-lexical-boundary-query-pair";
15pub const XSS_JAVASCRIPT_LEXICAL_BOUNDARY_QUERY_PAIR_REVISION: u32 = 1;
16
17/// Exact scanner-owned lexical tokens shared by payload derivation and
18/// response correlation. The canonical case identity is accepted once here;
19/// downstream code never rebuilds these comments independently.
20#[derive(Clone, PartialEq, Eq)]
21pub(crate) struct XssJavascriptLexicalProbeTokens {
22    control: String,
23    boundary_comment: String,
24    tail_comment: String,
25}
26
27impl XssJavascriptLexicalProbeTokens {
28    pub(crate) fn from_identity(identity: &str) -> Option<Self> {
29        if identity.len() != 32
30            || !identity
31                .bytes()
32                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
33        {
34            return None;
35        }
36        Some(Self {
37            control: format!("venom-xss-js-control-{identity}"),
38            boundary_comment: format!("/*venom-xss-js-boundary-{identity}*/"),
39            tail_comment: format!("/*venom-xss-js-tail-{identity}*/"),
40        })
41    }
42
43    pub(crate) fn control(&self) -> &str {
44        &self.control
45    }
46
47    pub(crate) fn boundary_comment(&self) -> &str {
48        &self.boundary_comment
49    }
50
51    pub(crate) fn tail_comment(&self) -> &str {
52        &self.tail_comment
53    }
54}
55
56impl fmt::Debug for XssJavascriptLexicalProbeTokens {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter
59            .debug_struct("XssJavascriptLexicalProbeTokens")
60            .field("control_bytes", &self.control.len())
61            .field("boundary_bytes", &self.boundary_comment.len())
62            .field("tail_bytes", &self.tail_comment.len())
63            .field("values", &"<redacted>")
64            .finish()
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct XssJavascriptLexicalBoundaryQueryPairStrategy {
70    reference: PayloadStrategyRef,
71}
72
73impl XssJavascriptLexicalBoundaryQueryPairStrategy {
74    pub fn new() -> Self {
75        Self {
76            reference: PayloadStrategyRef::new(
77                XSS_JAVASCRIPT_LEXICAL_BOUNDARY_QUERY_PAIR_ID,
78                XSS_JAVASCRIPT_LEXICAL_BOUNDARY_QUERY_PAIR_REVISION,
79            )
80            .expect("the XSS JavaScript lexical-boundary strategy identity is static and valid"),
81        }
82    }
83
84    fn derive_value(seed: &[u8], role: PayloadVariantRole) -> Option<String> {
85        let seed = std::str::from_utf8(seed).ok()?;
86        let (family, identity) = seed.split_once(':')?;
87        if !matches!(family, "js-single" | "js-double" | "js-template") {
88            return None;
89        }
90        let tokens = XssJavascriptLexicalProbeTokens::from_identity(identity)?;
91
92        if role == PayloadVariantRole::Control {
93            return Some(tokens.control().to_owned());
94        }
95
96        let boundary = tokens.boundary_comment();
97        let tail = tokens.tail_comment();
98        match family {
99            "js-single" => Some(format!("'{boundary}+{tail}'")),
100            "js-double" => Some(format!("\"{boundary}+{tail}\"")),
101            "js-template" => Some(format!("`{boundary}+{tail}`")),
102            _ => None,
103        }
104    }
105}
106
107impl Default for XssJavascriptLexicalBoundaryQueryPairStrategy {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl PayloadStrategy for XssJavascriptLexicalBoundaryQueryPairStrategy {
114    fn strategy_ref(&self) -> &PayloadStrategyRef {
115        &self.reference
116    }
117
118    fn derive_one(
119        &self,
120        role: PayloadVariantRole,
121        seed: &PayloadSeed,
122        limits: PayloadStrategyLimits,
123    ) -> Result<PayloadArtifact, PayloadStrategyError> {
124        let value = Self::derive_value(seed.as_bytes(), role)
125            .ok_or(PayloadStrategyError::DerivationFailed)?;
126        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::{sync::Arc, thread};
134
135    const IDENTITY: &str = "0123456789abcdef0123456789abcdef";
136
137    fn limits() -> PayloadStrategyLimits {
138        PayloadStrategyLimits::new(128, 256).unwrap()
139    }
140
141    fn derive(family: &str, role: PayloadVariantRole) -> String {
142        let strategy = XssJavascriptLexicalBoundaryQueryPairStrategy::new();
143        let seed = PayloadSeed::new(format!("{family}:{IDENTITY}").into_bytes(), limits()).unwrap();
144        String::from_utf8(
145            strategy
146                .derive_one(role, &seed, limits())
147                .unwrap()
148                .as_bytes()
149                .to_vec(),
150        )
151        .unwrap()
152    }
153
154    #[test]
155    fn strategy_identity_and_exact_inert_wire_shapes_are_versioned() {
156        let strategy = XssJavascriptLexicalBoundaryQueryPairStrategy::new();
157        assert_eq!(
158            strategy.strategy_ref().id(),
159            "web.review.xss.javascript-lexical-boundary-query-pair"
160        );
161        assert_eq!(strategy.strategy_ref().revision(), 1);
162
163        let tokens = XssJavascriptLexicalProbeTokens::from_identity(IDENTITY).unwrap();
164        let boundary = tokens.boundary_comment();
165        let tail = tokens.tail_comment();
166        assert_eq!(
167            derive("js-single", PayloadVariantRole::Candidate),
168            format!("'{boundary}+{tail}'")
169        );
170        assert_eq!(
171            derive("js-double", PayloadVariantRole::Candidate),
172            format!("\"{boundary}+{tail}\"")
173        );
174        assert_eq!(
175            derive("js-template", PayloadVariantRole::Candidate),
176            format!("`{boundary}+{tail}`")
177        );
178        let debug = format!("{tokens:?}");
179        assert!(!debug.contains(IDENTITY));
180        assert!(!debug.contains(boundary));
181        assert!(!debug.contains(tail));
182        assert_eq!(
183            tokens.control(),
184            derive("js-single", PayloadVariantRole::Control)
185        );
186    }
187
188    #[test]
189    fn controls_are_context_safe_and_candidates_contain_no_executable_tokens() {
190        for family in ["js-single", "js-double", "js-template"] {
191            let control = derive(family, PayloadVariantRole::Control);
192            let candidate = derive(family, PayloadVariantRole::Candidate);
193            assert_eq!(control, format!("venom-xss-js-control-{IDENTITY}"));
194            assert_ne!(control, candidate);
195            assert!(candidate.len() <= 256);
196            assert!(!control.contains(['\'', '"', '`', '/', '+']));
197
198            let normalized = candidate.to_ascii_lowercase();
199            for forbidden in [
200                "alert",
201                "prompt",
202                "confirm",
203                "eval",
204                "function",
205                "fetch",
206                "xmlhttprequest",
207                "websocket",
208                "import(",
209                "document.",
210                "window.",
211                "cookie",
212                "localstorage",
213                "sessionstorage",
214                "settimeout",
215                "setinterval",
216                "javascript:",
217                "data:",
218                "http://",
219                "https://",
220            ] {
221                assert!(
222                    !normalized.contains(forbidden),
223                    "forbidden token {forbidden} in {candidate}"
224                );
225            }
226        }
227    }
228
229    #[test]
230    fn unknown_family_and_noncanonical_identity_fail_closed() {
231        let strategy = XssJavascriptLexicalBoundaryQueryPairStrategy::new();
232        for value in [
233            format!("unknown:{IDENTITY}"),
234            "js-single:short".to_owned(),
235            "js-double:0123456789ABCDEF0123456789ABCDEF".to_owned(),
236            "js-template:0123456789abcdef0123456789abcdeg".to_owned(),
237            format!("js-single:{IDENTITY}:extra"),
238            format!("js-single :{IDENTITY}"),
239        ] {
240            let seed = PayloadSeed::new(value.into_bytes(), limits()).unwrap();
241            for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
242                assert!(matches!(
243                    strategy.derive_one(role, &seed, limits()),
244                    Err(PayloadStrategyError::DerivationFailed)
245                ));
246            }
247        }
248    }
249
250    #[test]
251    fn derivation_is_repeatable_and_concurrency_safe() {
252        let strategy = Arc::new(XssJavascriptLexicalBoundaryQueryPairStrategy::new());
253        let seed_value = format!("js-template:{IDENTITY}").into_bytes();
254        let expected = strategy
255            .derive_one(
256                PayloadVariantRole::Candidate,
257                &PayloadSeed::new(seed_value.clone(), limits()).unwrap(),
258                limits(),
259            )
260            .unwrap();
261
262        let handles: Vec<_> = (0..8)
263            .map(|_| {
264                let strategy = Arc::clone(&strategy);
265                let seed_value = seed_value.clone();
266                thread::spawn(move || {
267                    strategy
268                        .derive_one(
269                            PayloadVariantRole::Candidate,
270                            &PayloadSeed::new(seed_value, limits()).unwrap(),
271                            limits(),
272                        )
273                        .unwrap()
274                })
275            })
276            .collect();
277
278        for handle in handles {
279            let artifact = handle.join().unwrap();
280            assert_eq!(artifact, expected);
281            assert_eq!(artifact.receipt(), expected.receipt());
282        }
283    }
284}