1use async_trait::async_trait;
9use url::Url;
10use uuid::Uuid;
11
12use crate::{
13 context::ScanContext,
14 contracts::{ScanFinding, ScanPhase},
15 error::ScannerError,
16 http_evidence::HttpProbeMethod,
17 legacy_discovery::BoundedHttpResponse,
18};
19
20const XSS_REFLECTION_ACTION_ID: &str = "legacy.observation.reflection";
21const REFLECTION_MARKER_PREFIX: &str = "venom-xss-reflection-";
22const MAX_REFLECTION_REQUESTS: usize = 18;
23const REQUESTS_PER_PARAMETER: usize = 3;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ReflectionContext {
31 HtmlText,
32 DoubleQuotedAttribute,
33 SingleQuotedAttribute,
34 TagSyntax,
35 ScriptElementText,
36 NonHtmlText,
37 Unknown,
38}
39
40pub struct XssScanner;
42
43impl XssScanner {
44 fn analyze_context(body: &[u8], marker: &str, content_type: Option<&str>) -> ReflectionContext {
45 let Some(body) = std::str::from_utf8(body).ok() else {
46 return ReflectionContext::Unknown;
47 };
48 let Some(offset) = body.find(marker) else {
49 return ReflectionContext::Unknown;
50 };
51 if !is_html_media_type(content_type) {
52 return if is_textual_media_type(content_type) {
53 ReflectionContext::NonHtmlText
54 } else {
55 ReflectionContext::Unknown
56 };
57 }
58
59 let before = &body[..offset];
60 let before_lower = before.to_ascii_lowercase();
61 let script_open = before_lower.rfind("<script");
62 let script_close = before_lower.rfind("</script");
63 if script_open.is_some() && script_open > script_close {
64 return ReflectionContext::ScriptElementText;
65 }
66
67 let last_open = before.rfind('<');
68 let last_close = before.rfind('>');
69 if let Some(open) = last_open.filter(|open| last_close.is_none_or(|close| close < *open)) {
70 let tag_prefix = &before[open..];
71 if !tag_prefix.matches('"').count().is_multiple_of(2) {
72 ReflectionContext::DoubleQuotedAttribute
73 } else if !tag_prefix.matches('\'').count().is_multiple_of(2) {
74 ReflectionContext::SingleQuotedAttribute
75 } else {
76 ReflectionContext::TagSyntax
77 }
78 } else {
79 ReflectionContext::HtmlText
80 }
81 }
82}
83
84#[async_trait]
85impl ScanPhase for XssScanner {
86 fn phase_number(&self) -> u8 {
87 6
88 }
89
90 fn name(&self) -> &'static str {
91 "Reflection Context Observer"
92 }
93
94 async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
95 ctx.log("Phase 6: bounded exact-reflection observation initiated".to_owned());
96 let snapshot = ctx.discovery_snapshot();
97 let mut observations = Vec::new();
98 let mut requests = 0_usize;
99
100 'endpoints: for (endpoint, parameters) in snapshot.endpoints() {
101 let endpoint = ctx.canonicalize_discovery_url(&Url::parse(endpoint)?)?;
102 for parameter in parameters {
103 if MAX_REFLECTION_REQUESTS.saturating_sub(requests) < REQUESTS_PER_PARAMETER {
104 break 'endpoints;
105 }
106 let marker = format!(
107 "{REFLECTION_MARKER_PREFIX}{}&probe",
108 Uuid::new_v4().simple()
109 );
110 let baseline = ctx
111 .verification_request(
112 XSS_REFLECTION_ACTION_ID,
113 HttpProbeMethod::Get,
114 endpoint.clone(),
115 )
116 .await?;
117 requests += 1;
118 let probe_url = replace_query_parameter(&endpoint, parameter, &marker);
119 let candidate = ctx
120 .verification_request(
121 XSS_REFLECTION_ACTION_ID,
122 HttpProbeMethod::Get,
123 probe_url.clone(),
124 )
125 .await?;
126 requests += 1;
127 let reproduction = ctx
128 .verification_request(XSS_REFLECTION_ACTION_ID, HttpProbeMethod::Get, probe_url)
129 .await?;
130 requests += 1;
131
132 let Some((context, media_type)) =
133 reproducible_exact_reflection(&baseline, &candidate, &reproduction, &marker)
134 else {
135 continue;
136 };
137
138 observations.push(ScanFinding {
139 phase: self.phase_number(),
140 module_name: self.name().to_owned(),
141 severity: "INFO".to_owned(),
142 description: format!(
143 "Parameter '{parameter}' reproducibly reflected a byte-exact benign nonce; this is not evidence of script execution and does not establish XSS."
144 ),
145 evidence: format!(
146 "endpoint={}; media_type={media_type}; syntactic_context={context:?}; exact_reflection=true; response_truncated=false",
147 endpoint_subject(&endpoint)
148 ),
149 });
150 }
151 }
152
153 ctx.log(format!(
154 "Phase 6: bounded reflection observation completed with {} INFO observations across {} requests",
155 observations.len(), requests
156 ));
157 Ok(observations)
158 }
159}
160
161fn reproducible_exact_reflection(
162 baseline: &BoundedHttpResponse,
163 candidate: &BoundedHttpResponse,
164 reproduction: &BoundedHttpResponse,
165 marker: &str,
166) -> Option<(ReflectionContext, String)> {
167 if [baseline, candidate, reproduction]
168 .into_iter()
169 .any(|response| response.body_truncated() || !(200..300).contains(&response.status()))
170 || body_contains(baseline.body(), marker)
171 || body_contains(baseline.body(), REFLECTION_MARKER_PREFIX)
172 || !body_contains(candidate.body(), marker)
173 || !body_contains(reproduction.body(), marker)
174 {
175 return None;
176 }
177
178 let candidate_media = normalized_media_type(candidate.content_type());
179 let reproduction_media = normalized_media_type(reproduction.content_type());
180 if candidate_media != reproduction_media {
181 return None;
182 }
183 let candidate_context =
184 XssScanner::analyze_context(candidate.body(), marker, candidate.content_type());
185 let reproduction_context =
186 XssScanner::analyze_context(reproduction.body(), marker, reproduction.content_type());
187 (candidate_context == reproduction_context).then_some((
188 candidate_context,
189 candidate_media.unwrap_or_else(|| "unknown".to_owned()),
190 ))
191}
192
193fn normalized_media_type(content_type: Option<&str>) -> Option<String> {
194 content_type
195 .and_then(|value| value.split(';').next())
196 .map(str::trim)
197 .filter(|value| !value.is_empty())
198 .map(str::to_ascii_lowercase)
199}
200
201fn is_html_media_type(content_type: Option<&str>) -> bool {
202 matches!(
203 normalized_media_type(content_type).as_deref(),
204 Some("text/html" | "application/xhtml+xml")
205 )
206}
207
208fn is_textual_media_type(content_type: Option<&str>) -> bool {
209 normalized_media_type(content_type).is_some_and(|media| {
210 media.starts_with("text/")
211 || matches!(
212 media.as_str(),
213 "application/json" | "application/xml" | "application/xhtml+xml"
214 )
215 || media.ends_with("+json")
216 || media.ends_with("+xml")
217 })
218}
219
220fn replace_query_parameter(endpoint: &Url, name: &str, value: &str) -> Url {
221 let retained = endpoint
222 .query_pairs()
223 .filter(|(existing, _)| existing != name)
224 .map(|(existing, value)| (existing.into_owned(), value.into_owned()))
225 .collect::<Vec<_>>();
226 let mut mutated = endpoint.clone();
227 mutated
228 .query_pairs_mut()
229 .clear()
230 .extend_pairs(retained)
231 .append_pair(name, value);
232 mutated
233}
234
235fn body_contains(body: &[u8], needle: &str) -> bool {
236 !needle.is_empty()
237 && body
238 .windows(needle.len())
239 .any(|window| window == needle.as_bytes())
240}
241
242fn endpoint_subject(endpoint: &Url) -> String {
243 let mut subject = endpoint.clone();
244 subject.set_query(None);
245 subject.set_fragment(None);
246 subject.to_string()
247}
248
249#[cfg(test)]
250mod tests {
251 use std::sync::{
252 atomic::{AtomicUsize, Ordering},
253 Arc,
254 };
255 use std::time::Duration;
256
257 use tokio::{
258 io::{AsyncReadExt, AsyncWriteExt},
259 net::TcpListener,
260 task::JoinHandle,
261 };
262
263 use super::*;
264 use crate::{legacy_discovery::VerificationLimits, runner::ScanRunner};
265 use termivar_core::{OutcomeStatus, SecuritySeverity};
266
267 struct LocalFixture {
268 target: Url,
269 requests: Arc<AtomicUsize>,
270 task: JoinHandle<()>,
271 }
272
273 impl Drop for LocalFixture {
274 fn drop(&mut self) {
275 self.task.abort();
276 }
277 }
278
279 async fn serve_fixture(
280 max_requests: usize,
281 handler: impl Fn(&Url) -> String + Send + Sync + 'static,
282 ) -> LocalFixture {
283 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
284 let address = listener.local_addr().unwrap();
285 let requests = Arc::new(AtomicUsize::new(0));
286 let observed_requests = Arc::clone(&requests);
287 let handler = Arc::new(handler);
288 let task = tokio::spawn(async move {
289 for _ in 0..max_requests {
290 let (mut stream, _) = listener.accept().await.unwrap();
291 let mut request = Vec::new();
292 loop {
293 let mut chunk = [0_u8; 1_024];
294 let read = stream.read(&mut chunk).await.unwrap();
295 if read == 0 {
296 break;
297 }
298 request.extend_from_slice(&chunk[..read]);
299 if request.windows(4).any(|window| window == b"\r\n\r\n") {
300 break;
301 }
302 assert!(request.len() <= 16 * 1_024);
303 }
304 observed_requests.fetch_add(1, Ordering::SeqCst);
305 let request_target = String::from_utf8_lossy(&request)
306 .lines()
307 .next()
308 .and_then(|line| line.split_ascii_whitespace().nth(1))
309 .unwrap()
310 .to_owned();
311 let url = Url::parse(&format!("http://fixture{request_target}")).unwrap();
312 let body = handler(&url);
313 let wire = format!(
314 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
315 body.len(), body
316 );
317 stream.write_all(wire.as_bytes()).await.unwrap();
318 stream.shutdown().await.unwrap();
319 }
320 });
321
322 LocalFixture {
323 target: Url::parse(&format!("http://{address}/reflect?input=baseline")).unwrap(),
324 requests,
325 task,
326 }
327 }
328
329 fn scan_context(target: Url) -> ScanContext {
330 let limits = VerificationLimits::new()
331 .with_max_requests(8)
332 .unwrap()
333 .with_request_timeout(Duration::from_secs(1))
334 .unwrap()
335 .with_max_wall_time(Duration::from_secs(10))
336 .unwrap()
337 .with_body_limits(64 * 1_024, 16 * 1_024)
338 .unwrap();
339 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
340 ScanContext::new_with_verification_limits(target, reqwest::Client::new(), telemetry, limits)
341 }
342
343 fn last_parameter(url: &Url, name: &str) -> Option<String> {
344 url.query_pairs()
345 .filter(|(parameter, _)| parameter == name)
346 .map(|(_, value)| value.into_owned())
347 .last()
348 }
349
350 #[test]
351 fn phase_identity_is_stable() {
352 assert_eq!(XssScanner.phase_number(), 6);
353 assert_eq!(XssScanner.name(), "Reflection Context Observer");
354 }
355
356 #[test]
357 fn context_labels_are_syntax_only() {
358 let marker = "venom-xss-reflection-example&probe";
359 assert_eq!(
360 XssScanner::analyze_context(
361 format!("<div>{marker}</div>").as_bytes(),
362 marker,
363 Some("text/html")
364 ),
365 ReflectionContext::HtmlText
366 );
367 assert_eq!(
368 XssScanner::analyze_context(
369 format!(r#"<input value="{marker}">"#).as_bytes(),
370 marker,
371 Some("text/html")
372 ),
373 ReflectionContext::DoubleQuotedAttribute
374 );
375 assert_eq!(
376 XssScanner::analyze_context(
377 format!("<script>const value = '{marker}';</script>").as_bytes(),
378 marker,
379 Some("text/html")
380 ),
381 ReflectionContext::ScriptElementText
382 );
383 assert_eq!(
384 XssScanner::analyze_context(marker.as_bytes(), marker, Some("application/json")),
385 ReflectionContext::NonHtmlText
386 );
387 }
388
389 #[tokio::test]
390 async fn exact_reproducible_reflection_is_info_only() {
391 let fixture = serve_fixture(3, |url| {
392 let value = last_parameter(url, "input").unwrap();
393 if value.starts_with(REFLECTION_MARKER_PREFIX) {
394 format!("<div>{value}</div>")
395 } else {
396 "<div>ordinary page</div>".to_owned()
397 }
398 })
399 .await;
400 let context = scan_context(fixture.target.clone());
401
402 let observations = XssScanner.execute(&context).await.unwrap();
403
404 assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
405 assert_eq!(observations.len(), 1);
406 assert_eq!(observations[0].severity, "INFO");
407 assert!(observations[0].description.contains("not evidence"));
408 assert!(observations[0]
409 .description
410 .contains("does not establish XSS"));
411 assert!(observations[0]
412 .evidence
413 .contains("syntactic_context=HtmlText"));
414 }
415
416 #[tokio::test]
417 async fn runner_projects_reflection_as_unverified_info_only() {
418 let fixture = serve_fixture(3, |url| {
419 let value = last_parameter(url, "input").unwrap();
420 if value.starts_with(REFLECTION_MARKER_PREFIX) {
421 format!("<div>{value}</div>")
422 } else {
423 "<div>ordinary page</div>".to_owned()
424 }
425 })
426 .await;
427 let context = scan_context(fixture.target.clone());
428 let mut runner = ScanRunner::new();
429 runner.register_phase(Box::new(XssScanner));
430
431 let report = runner.run_pipeline(context).await.unwrap();
432
433 assert_eq!(report.outcomes().len(), 1);
434 let observation = &report.outcomes()[0];
435 assert_eq!(observation.severity(), SecuritySeverity::Info);
436 assert_eq!(observation.disposition(), OutcomeStatus::Unknown);
437 assert!(observation.verification_outcome().is_none());
438 assert!(observation.evidence_ids().is_empty());
439 }
440
441 #[tokio::test]
442 async fn encoded_or_inert_echo_does_not_establish_xss() {
443 let fixture = serve_fixture(3, |url| {
444 let value = last_parameter(url, "input").unwrap();
445 if value.starts_with(REFLECTION_MARKER_PREFIX) {
446 format!("<pre>{}</pre>", value.replace('&', "&"))
447 } else {
448 "<div>ordinary page</div>".to_owned()
449 }
450 })
451 .await;
452 let context = scan_context(fixture.target.clone());
453
454 let observations = XssScanner.execute(&context).await.unwrap();
455
456 assert!(observations.is_empty());
457 assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
458 }
459}