termivar_scanner/payload_strategies/
xss_javascript_lexical_boundary_query_pair.rs1use 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#[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}