#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FindingClass {
None,
Reflected { params: Vec<String> },
ExploitConfirmed {
params: Vec<String>,
sink: String,
message: String,
},
}
impl FindingClass {
#[must_use]
pub fn summary(&self) -> Option<String> {
match self {
FindingClass::None => None,
FindingClass::Reflected { params } => Some(format!(
"REFLECTED — input(s) {} echoed in the response (review for XSS)",
params.join(", ")
)),
FindingClass::ExploitConfirmed {
params,
sink,
message,
} => Some(format!(
"EXPLOIT CONFIRMED — input(s) {} reflect and EXECUTE `{sink}({message})` in a sandbox (client-side exploit)",
params.join(", ")
)),
}
}
#[must_use]
pub fn is_exploit(&self) -> bool {
matches!(self, FindingClass::ExploitConfirmed { .. })
}
}
const MIN_REFLECT_LEN: usize = 6;
#[must_use]
pub fn reflected_inputs(inputs: &[String], body: &[u8]) -> Vec<String> {
let mut hits = Vec::new();
for v in inputs {
if v.len() < MIN_REFLECT_LEN {
continue;
}
if contains(body, v.as_bytes()) && !hits.contains(v) {
hits.push(v.clone());
}
}
hits
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
#[must_use]
pub fn is_html_like(content_type: &str) -> bool {
let ct = content_type.to_ascii_lowercase();
ct.contains("text/html") || ct.contains("application/xhtml") || ct.contains("image/svg")
}
#[must_use]
pub fn classify(
inputs: &[String],
body: &[u8],
content_type: &str,
prove: bool,
detonate: impl FnOnce(&[u8]) -> Option<DetonationVerdict>,
) -> FindingClass {
let params = reflected_inputs(inputs, body);
if params.is_empty() {
return FindingClass::None;
}
if prove
&& is_html_like(content_type)
&& let Some(v) = detonate(body)
&& v.executed
{
return FindingClass::ExploitConfirmed {
params,
sink: v.sink,
message: v.message,
};
}
FindingClass::Reflected { params }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetonationVerdict {
pub executed: bool,
pub sink: String,
pub message: String,
}
#[must_use]
pub fn extract_request_inputs(
uri: &str,
body: Option<&[u8]>,
req_content_type: &str,
) -> Vec<String> {
let mut out = Vec::new();
if let Some((_, q)) = uri.split_once('?') {
collect_pairs(q.as_bytes(), &mut out);
}
if req_content_type
.to_ascii_lowercase()
.contains("x-www-form-urlencoded")
&& let Some(b) = body
{
collect_pairs(b, &mut out);
}
out
}
fn collect_pairs(raw: &[u8], out: &mut Vec<String>) {
for pair in raw.split(|&c| c == b'&') {
let Some(eq) = pair.iter().position(|&c| c == b'=') else {
continue;
};
let decoded = percent_decode(&pair[eq + 1..]);
if !decoded.is_empty() && !out.contains(&decoded) {
out.push(decoded);
}
}
}
fn percent_decode(v: &[u8]) -> String {
let mut bytes = Vec::with_capacity(v.len());
let mut i = 0;
while i < v.len() {
match v[i] {
b'+' => {
bytes.push(b' ');
i += 1;
}
b'%' if i + 2 < v.len() => match (hexval(v[i + 1]), hexval(v[i + 2])) {
(Some(h), Some(l)) => {
bytes.push(h * 16 + l);
i += 3;
}
_ => {
bytes.push(b'%');
i += 1;
}
},
c => {
bytes.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
fn hexval(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reflected_inputs_finds_long_values_only() {
let body = b"<html>q=hello and payload <script>alert(1)</script> here</html>";
let inputs = vec![
"q".to_string(), "hello".to_string(), "<script>alert(1)</script>".to_string(), "notpresent-longvalue".to_string(), ];
let hits = reflected_inputs(&inputs, body);
assert_eq!(hits, vec!["<script>alert(1)</script>".to_string()]);
}
#[test]
fn classify_none_when_no_reflection() {
let c = classify(
&["notpresent-longvalue".into()],
b"<html>clean</html>",
"text/html",
true,
|_| panic!("must not detonate when nothing reflected"),
);
assert_eq!(c, FindingClass::None);
}
#[test]
fn classify_reflected_when_not_html() {
let c = classify(
&["<script>alert(1)</script>".into()],
br#"{"q":"<script>alert(1)</script>"}"#,
"application/json",
true,
|_| panic!("must not detonate a non-HTML response"),
);
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn classify_reflected_when_detonate_unavailable() {
let c = classify(
&["<script>alert(1)</script>".into()],
b"<html><script>alert(1)</script></html>",
"text/html",
true,
|_| None, );
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn classify_exploit_confirmed_when_execution_proven() {
let c = classify(
&["<script>alert(1)</script>".into()],
b"<html><script>alert(1)</script></html>",
"text/html; charset=utf-8",
true,
|_| {
Some(DetonationVerdict {
executed: true,
sink: "alert".into(),
message: "1".into(),
})
},
);
assert!(c.is_exploit(), "{c:?}");
assert!(c.summary().unwrap().contains("EXPLOIT CONFIRMED"));
assert!(c.summary().unwrap().contains("alert(1)"));
}
#[test]
fn classify_skips_detonation_when_prove_off() {
let c = classify(
&["<script>alert(1)</script>".into()],
b"<html><script>alert(1)</script></html>",
"text/html",
false, |_| panic!("must not detonate when prove=false"),
);
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn extract_inputs_decodes_query_and_form() {
let q = extract_request_inputs(
"http://t/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E&page=2",
None,
"",
);
assert!(
q.contains(&"<script>alert(1)</script>".to_string()),
"{q:?}"
);
let f = extract_request_inputs(
"http://t/post",
Some(b"name=%3Cimg+src%3Dx+onerror%3Dalert(1)%3E"),
"application/x-www-form-urlencoded",
);
assert!(f.iter().any(|v| v.contains("onerror=alert(1)")), "{f:?}");
let j = extract_request_inputs("http://t/post", Some(b"{\"a\":\"b\"}"), "application/json");
assert!(j.is_empty(), "{j:?}");
}
#[test]
fn extract_then_reflect_end_to_end() {
let inputs =
extract_request_inputs("http://t/r?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", None, "");
let body = b"<html>you searched: <script>alert(1)</script></html>";
assert_eq!(
reflected_inputs(&inputs, body),
vec!["<script>alert(1)</script>".to_string()]
);
}
#[test]
fn is_html_like_recognizes_executable_types() {
assert!(is_html_like("text/html; charset=utf-8"));
assert!(is_html_like("image/svg+xml"));
assert!(is_html_like("application/xhtml+xml"));
assert!(!is_html_like("application/json"));
assert!(!is_html_like("text/plain"));
}
fn no_detonate(_: &[u8]) -> Option<DetonationVerdict> {
panic!("detonate hook must not be called on this path");
}
fn fired(_: &[u8]) -> Option<DetonationVerdict> {
Some(DetonationVerdict {
executed: true,
sink: "alert".into(),
message: "1".into(),
})
}
fn inv(vs: &[&str]) -> Vec<String> {
vs.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn reflect_value_exactly_one_below_min_never_reflects() {
let body = b"prefix ABCDE suffix";
assert!(reflected_inputs(&inv(&["ABCDE"]), body).is_empty());
}
#[test]
fn reflect_value_exactly_at_min_reflects() {
let body = b"prefix ABCDEF suffix";
assert_eq!(reflected_inputs(&inv(&["ABCDEF"]), body), inv(&["ABCDEF"]));
}
#[test]
fn reflect_short_value_present_in_body_is_ignored() {
let body = b"<input type=on checked onmouseover=on>";
assert!(reflected_inputs(&inv(&["on"]), body).is_empty());
}
#[test]
fn reflect_value_not_in_body_is_not_reflected() {
let body = b"<html>totally unrelated content here</html>";
assert!(reflected_inputs(&inv(&["payload-not-present"]), body).is_empty());
}
#[test]
fn reflect_empty_inputs_yields_nothing() {
assert!(reflected_inputs(&[], b"<html>body</html>").is_empty());
}
#[test]
fn reflect_empty_body_yields_nothing() {
assert!(reflected_inputs(&inv(&["longenoughvalue"]), b"").is_empty());
}
#[test]
fn reflect_value_longer_than_body_not_reflected() {
assert!(
reflected_inputs(&inv(&["this-needle-is-way-longer-than-body"]), b"short").is_empty()
);
}
#[test]
fn reflect_dedups_duplicate_inputs() {
let body = b"<p>repeated-payload appears repeated-payload twice</p>";
let hits = reflected_inputs(&inv(&["repeated-payload", "repeated-payload"]), body);
assert_eq!(
hits,
inv(&["repeated-payload"]),
"duplicates must collapse to one"
);
}
#[test]
fn reflect_preserves_first_seen_order() {
let body = b"alpha-input then beta-input then gamma-input";
let hits = reflected_inputs(&inv(&["beta-input", "alpha-input", "gamma-input"]), body);
assert_eq!(hits, inv(&["beta-input", "alpha-input", "gamma-input"]));
}
#[test]
fn reflect_multiple_distinct_hits_all_returned() {
let body = b"first-payload and second-payload both reflect";
let hits = reflected_inputs(&inv(&["first-payload", "second-payload"]), body);
assert_eq!(hits, inv(&["first-payload", "second-payload"]));
}
#[test]
fn reflect_overlapping_substrings_each_matched_independently() {
let body = b"xxlongpayloadxx";
let hits = reflected_inputs(&inv(&["longpayload", "ngpayload"]), body);
assert_eq!(hits, inv(&["longpayload", "ngpayload"]));
}
#[test]
fn reflect_multibyte_unicode_value_matches_on_bytes() {
let value = "café☕menu";
let mut body = b"<div>".to_vec();
body.extend_from_slice(value.as_bytes());
body.extend_from_slice(b"</div>");
assert_eq!(reflected_inputs(&inv(&[value]), &body), inv(&[value]));
}
#[test]
fn reflect_uses_byte_length_not_char_count() {
let value = "☕☕"; assert_eq!(value.len(), 6);
let mut body = b"prefix".to_vec();
body.extend_from_slice(value.as_bytes());
assert_eq!(reflected_inputs(&inv(&[value]), &body), inv(&[value]));
}
#[test]
fn reflect_multibyte_partial_byte_match_does_not_false_positive() {
let body = "naïveword".as_bytes();
assert!(reflected_inputs(&inv(&["naïveOTHER"]), body).is_empty());
}
#[test]
fn decode_percent_41_becomes_a() {
let out = extract_request_inputs("h://t?x=%41%41%41%41%41%41", None, "");
assert_eq!(out, inv(&["AAAAAA"]));
}
#[test]
fn decode_plus_becomes_space() {
let out = extract_request_inputs("h://t?x=a+b+c+d+e", None, "");
assert_eq!(out, inv(&["a b c d e"]));
}
#[test]
fn decode_mixed_percent_and_plus() {
let out = extract_request_inputs("h://t?x=hi+%41%42+there", None, "");
assert_eq!(out, inv(&["hi AB there"]));
}
#[test]
fn decode_lowercase_hex() {
let out = extract_request_inputs("h://t?path=a%2fb%2fcccc", None, "");
assert_eq!(out, inv(&["a/b/cccc"]));
}
#[test]
fn decode_invalid_hex_capital_z_kept_literal() {
let out = extract_request_inputs("h://t?x=ab%ZZcd", None, "");
assert_eq!(out, inv(&["ab%ZZcd"]));
}
#[test]
fn decode_invalid_hex_single_bad_nibble_kept_literal() {
let out = extract_request_inputs("h://t?x=start%4Zend", None, "");
assert_eq!(out, inv(&["start%4Zend"]));
}
#[test]
fn decode_trailing_lone_percent_kept_literal() {
let out = extract_request_inputs("h://t?x=value1%", None, "");
assert_eq!(out, inv(&["value1%"]));
}
#[test]
fn decode_trailing_percent_one_hex_kept_literal() {
let out = extract_request_inputs("h://t?x=value1%4", None, "");
assert_eq!(out, inv(&["value1%4"]));
}
#[test]
fn decode_percent_pair_at_exact_end_decodes() {
let out = extract_request_inputs("h://t?x=AAAAA%42Z", None, "");
assert_eq!(out, inv(&["AAAAABZ"]));
}
#[test]
fn decode_empty_value_after_eq_is_dropped() {
let out = extract_request_inputs("h://t?empty=&real=longvalue", None, "");
assert_eq!(out, inv(&["longvalue"]));
}
#[test]
fn decode_pair_without_eq_is_skipped() {
let out = extract_request_inputs("h://t?bareflag&k=realvalue", None, "");
assert_eq!(out, inv(&["realvalue"]));
}
#[test]
fn decode_multiple_eq_splits_on_first() {
let out = extract_request_inputs("h://t?k=a=b=c=d", None, "");
assert_eq!(out, inv(&["a=b=c=d"]));
}
#[test]
fn decode_empty_pairs_from_double_amp_ignored() {
let out = extract_request_inputs("h://t?a=firstval&&&b=secondval", None, "");
assert_eq!(out, inv(&["firstval", "secondval"]));
}
#[test]
fn decode_leading_and_trailing_amp_ignored() {
let out = extract_request_inputs("h://t?&k=onlyvalue&", None, "");
assert_eq!(out, inv(&["onlyvalue"]));
}
#[test]
fn decode_dedups_repeated_values_across_pairs() {
let out = extract_request_inputs("h://t?a=samevalue&b=samevalue", None, "");
assert_eq!(out, inv(&["samevalue"]));
}
#[test]
fn decode_percent_byte_then_invalid_continues_parsing() {
let out = extract_request_inputs("h://t?x=%3Cscript%3E", None, "");
assert_eq!(out, inv(&["<script>"]));
}
#[test]
fn extract_query_only_no_body() {
let out = extract_request_inputs("h://t?q=querypayload", None, "");
assert_eq!(out, inv(&["querypayload"]));
}
#[test]
fn extract_form_only_no_query() {
let out = extract_request_inputs(
"h://t/post",
Some(b"field=formpayload"),
"application/x-www-form-urlencoded",
);
assert_eq!(out, inv(&["formpayload"]));
}
#[test]
fn extract_both_query_and_form_combined() {
let out = extract_request_inputs(
"h://t/post?q=querypayload",
Some(b"field=formpayload"),
"application/x-www-form-urlencoded",
);
assert_eq!(out, inv(&["querypayload", "formpayload"]));
}
#[test]
fn extract_no_query_no_body_is_empty() {
assert!(extract_request_inputs("h://t/plain", None, "").is_empty());
}
#[test]
fn extract_uri_without_question_mark_yields_no_query_inputs() {
let out = extract_request_inputs(
"h://t/path-only",
Some(b"f=bodyvalue"),
"application/x-www-form-urlencoded",
);
assert_eq!(out, inv(&["bodyvalue"]));
}
#[test]
fn extract_json_body_ignored_even_with_form_shaped_bytes() {
let out = extract_request_inputs(
"h://t/post?q=querypayload",
Some(b"injected=shouldnotappear"),
"application/json",
);
assert_eq!(out, inv(&["querypayload"]));
assert!(!out.iter().any(|v| v.contains("shouldnotappear")));
}
#[test]
fn extract_form_content_type_case_insensitive() {
let out = extract_request_inputs(
"h://t/post",
Some(b"f=mixedcasevalue"),
"Application/X-WWW-Form-UrlEncoded",
);
assert_eq!(out, inv(&["mixedcasevalue"]));
}
#[test]
fn extract_form_content_type_with_charset_param() {
let out = extract_request_inputs(
"h://t/post",
Some(b"f=charsetvalue"),
"application/x-www-form-urlencoded; charset=utf-8",
);
assert_eq!(out, inv(&["charsetvalue"]));
}
#[test]
fn extract_form_body_present_but_empty_slice() {
let out = extract_request_inputs(
"h://t/post?q=onlyquery",
Some(b""),
"application/x-www-form-urlencoded",
);
assert_eq!(out, inv(&["onlyquery"]));
}
#[test]
fn is_html_like_case_insensitive() {
assert!(is_html_like("TEXT/HTML"));
assert!(is_html_like("Image/SVG+XML"));
assert!(is_html_like("Application/XHTML+XML"));
}
#[test]
fn is_html_like_rejects_other_types() {
assert!(!is_html_like("application/json; charset=utf-8"));
assert!(!is_html_like("text/plain; charset=utf-8"));
assert!(!is_html_like("application/octet-stream"));
assert!(!is_html_like("image/png"));
assert!(!is_html_like(""));
}
#[test]
fn classify_short_input_in_html_never_detonates() {
let c = classify(
&inv(&["abc"]),
b"<html>abc</html>",
"text/html",
true,
no_detonate,
);
assert_eq!(c, FindingClass::None);
}
#[test]
fn classify_value_absent_from_body_is_none() {
let c = classify(
&inv(&["payload-not-here"]),
b"<html>clean page</html>",
"text/html",
true,
no_detonate,
);
assert_eq!(c, FindingClass::None);
}
#[test]
fn classify_plain_text_response_never_detonates() {
let c = classify(
&inv(&["<script>alert(1)</script>"]),
b"reflected <script>alert(1)</script> as plain text",
"text/plain",
true,
no_detonate,
);
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn classify_json_response_never_detonates() {
let c = classify(
&inv(&["<script>alert(1)</script>"]),
br#"{"echo":"<script>alert(1)</script>"}"#,
"application/json",
true,
no_detonate,
);
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn classify_reflected_carries_param_list() {
let c = classify(
&inv(&["first-payload", "second-payload"]),
b"first-payload and second-payload",
"application/json",
false,
no_detonate,
);
match c {
FindingClass::Reflected { params } => {
assert_eq!(params, inv(&["first-payload", "second-payload"]));
}
other => panic!("expected Reflected, got {other:?}"),
}
}
#[test]
fn classify_exploit_only_when_executed_true() {
let c = classify(
&inv(&["<script>alert(1)</script>"]),
b"<html><script>alert(1)</script></html>",
"text/html",
true,
|_| {
Some(DetonationVerdict {
executed: false,
sink: "alert".into(),
message: "1".into(),
})
},
);
assert!(!c.is_exploit());
assert!(matches!(c, FindingClass::Reflected { .. }));
}
#[test]
fn classify_exploit_confirmed_on_svg_response() {
let c = classify(
&inv(&["<svg onload=alert(1)>"]),
b"<svg onload=alert(1)></svg>",
"image/svg+xml",
true,
fired,
);
assert!(c.is_exploit(), "{c:?}");
}
#[test]
fn classify_exploit_confirmed_carries_sink_and_params() {
let c = classify(
&inv(&["<script>document.cookie</script>"]),
b"<html><script>document.cookie</script></html>",
"text/html",
true,
|_| {
Some(DetonationVerdict {
executed: true,
sink: "eval".into(),
message: "document.cookie".into(),
})
},
);
match &c {
FindingClass::ExploitConfirmed {
params,
sink,
message,
} => {
assert_eq!(params, &inv(&["<script>document.cookie</script>"]));
assert_eq!(sink, "eval");
assert_eq!(message, "document.cookie");
}
other => panic!("expected ExploitConfirmed, got {other:?}"),
}
}
#[test]
fn classify_detonate_returns_none_degrades_to_reflected() {
let c = classify(
&inv(&["<script>alert(1)</script>"]),
b"<html><script>alert(1)</script></html>",
"text/html",
true,
|_| None,
);
assert!(matches!(c, FindingClass::Reflected { .. }));
assert!(!c.is_exploit());
}
#[test]
fn summary_none_is_none() {
assert!(FindingClass::None.summary().is_none());
assert!(!FindingClass::None.is_exploit());
}
#[test]
fn summary_reflected_lists_all_params() {
let fc = FindingClass::Reflected {
params: inv(&["alpha-param", "beta-param"]),
};
let s = fc.summary().expect("reflected has a summary");
assert!(s.contains("REFLECTED"), "{s}");
assert!(s.contains("alpha-param"), "{s}");
assert!(s.contains("beta-param"), "{s}");
assert!(!fc.is_exploit());
}
#[test]
fn summary_exploit_contains_sink_and_args() {
let fc = FindingClass::ExploitConfirmed {
params: inv(&["xss-param"]),
sink: "alert".into(),
message: "1337".into(),
};
let s = fc.summary().expect("exploit has a summary");
assert!(s.contains("EXPLOIT CONFIRMED"), "{s}");
assert!(s.contains("xss-param"), "{s}");
assert!(s.contains("alert(1337)"), "{s}");
assert!(fc.is_exploit());
}
#[test]
fn detonation_verdict_equality_and_clone() {
let v = DetonationVerdict {
executed: true,
sink: "alert".into(),
message: "1".into(),
};
assert_eq!(v.clone(), v);
let differ = DetonationVerdict {
executed: false,
..v.clone()
};
assert_ne!(differ, v);
}
#[test]
fn property_value_under_min_never_reflects_any_body() {
for len in 0..MIN_REFLECT_LEN {
let needle: String = std::iter::repeat_n('Z', len).collect();
let mut body = b"head".to_vec();
body.extend_from_slice(needle.as_bytes());
body.extend_from_slice(b"tail");
assert!(
reflected_inputs(&inv(&[needle.as_str()]), &body).is_empty(),
"len {len} reflected but is below MIN_REFLECT_LEN"
);
}
}
#[test]
fn property_value_at_or_over_min_present_always_reflects() {
for len in MIN_REFLECT_LEN..MIN_REFLECT_LEN + 8 {
let needle: String = std::iter::repeat_n('Q', len).collect();
let mut body = b"head".to_vec();
body.extend_from_slice(needle.as_bytes());
body.extend_from_slice(b"tail");
assert_eq!(
reflected_inputs(&inv(&[needle.as_str()]), &body),
inv(&[needle.as_str()]),
"len {len} present but did not reflect"
);
}
}
#[test]
fn property_roundtrip_decode_then_reflect_matches() {
let payloads = [
"<script>alert(1)</script>",
"<img src=x onerror=alert(2)>",
"javascript:void(0)//xss",
"café-payload-unicode",
];
for p in payloads {
let encoded = percent_encode_for_test(p);
let uri = format!("h://t?inj={encoded}");
let inputs = extract_request_inputs(&uri, None, "");
assert_eq!(inputs, inv(&[p]), "decode mismatch for {p}");
let mut body = b"<div>".to_vec();
body.extend_from_slice(p.as_bytes());
body.extend_from_slice(b"</div>");
assert_eq!(reflected_inputs(&inputs, &body), inv(&[p]));
}
}
fn percent_encode_for_test(s: &str) -> String {
let mut out = String::new();
for &b in s.as_bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
}