Skip to main content

termivar_scanner/phases/
phase6_xss.rs

1//! # Phase 6: bounded reflection observation
2//!
3//! This legacy phase records reproducible, byte-exact reflection of a benign
4//! nonce. Reflection is an input/output observation, not proof that a browser
5//! parsed or executed attacker-controlled script. Consequently this phase
6//! does not establish an XSS claim.
7
8use 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/// Conservative lexical location of an exact reflection.
26///
27/// These labels describe response syntax only. None of them establish
28/// executable browser behavior or XSS.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ReflectionContext {
31    HtmlText,
32    DoubleQuotedAttribute,
33    SingleQuotedAttribute,
34    TagSyntax,
35    ScriptElementText,
36    NonHtmlText,
37    Unknown,
38}
39
40/// Legacy phase-six reflection observer.
41pub 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('&', "&amp;"))
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}