use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i:on(?:error|load\w*|click|mouse\w*|key\w*|focus|blur|before\w*|change|submit|reset|scroll|resize|abort|select|start|drag\w*|drop|play|pause|ended|volumechange|animation\w*|transition\w*|touch\w*|pointer\w*|wheel|auxclick|can\w*|close|cue\w*|dblclick|duration\w*|emptied|fullscreenchange|got\w*|input|invalid|lost\w*|offline|online|page\w*|pop\w*|progress|ratechange|securitypolicyviolation|seek\w*|show|stalled|suspend|timeupdate|toggle|waiting|paste|copy|cut|contextmenu)\s*=",
r"|javascript:[^\s]",
r"|vbscript:[^\s])",
))
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i:<script[\s/>]",
r"|<iframe[\s/>]",
r"|<embed[\s/>]",
r"|<object[\s/>]",
r"|<link[\s/>]",
r"|expression\s*\()",
))
.unwrap()
});
pub struct XssDetector;
impl Detector for XssDetector {
fn name(&self) -> &'static str {
"xss"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Critical,
"XSS cross-site scripting detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Low,
"XSS tag present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn det() -> XssDetector {
XssDetector
}
fn assert_hit(input: &str) {
assert_hit_at(input, Severity::Critical);
}
fn assert_hit_at(input: &str, severity: Severity) {
crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
}
#[test]
fn name_is_xss() {
assert_eq!(det().name(), "xss");
}
#[test]
fn detects_common_payloads() {
for (input, severity) in [
("<img src=x onerror=alert(1)>", Severity::Critical),
("javascript:alert(document.cookie)", Severity::Critical),
("vbscript:msgbox(1)", Severity::Critical),
("<svg onload=alert(1)>", Severity::Critical),
("<script>alert(1)</script>", Severity::Low),
("<iframe src=\"//evil.com\"></iframe>", Severity::Low),
("data:text/html,<script>alert(1)</script>", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
#[test]
fn tag_presence_is_low_not_critical() {
for input in [
r#"<link rel="stylesheet" href="/s.css">"#,
r#"<script src="/app.js" defer></script>"#,
r#"<iframe src="https://maps.example.com/embed"></iframe>"#,
r#"<embed src="/whitepaper.pdf" type="application/pdf">"#,
r#"<object data="logo.svg" type="image/svg+xml"></object>"#,
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn detects_event_handler_families() {
for input in [
"<img src=x onerror=alert(1)>",
"<body onload=alert(1)>",
"<div onclick=alert(1)>x</div>",
"<div onmouseenter=alert(1)>",
"<img src=x onpointerover=alert(1)>",
"<input onpaste=alert(1)>",
"<b ondragstart=alert(1)>",
"<div oncontextmenu=alert(1)>",
"<div onanimationiteration=x>",
"<div onbeforeunload=x>",
] {
assert_hit(input);
}
}
#[test]
fn real_world_inputs_not_detected() {
for input in [
r#"<!DOCTYPE html>
<html lang="en"><head><title>Docs</title></head>
<body><h1>Welcome</h1><p>Read the <a href="/about">about page</a>.</p>
<img src="/logo.png" alt="logo" width="120">
</body></html>"#,
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M12 2 2 22h20z" fill="none" stroke="#333" stroke-width="2"/>
<circle cx="12" cy="12" r="4" fill="#09f"/>
</svg>"##,
r#"const url = window.location.href;
document.cookie = "session=abc; path=/";
const total = price * qty && discount > 0;
const out = eval(userCode);"#,
"body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
"| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
"if (a && b) { run(); }",
"The window.location object gives you the current URL.",
"Cookies are read via document.cookie and written with document.write().",
"The legacy page used vbscript: syntax in an old IE-only build.",
"phone=555-1234",
"zone=UTC&timezone=Europe/Berlin",
"<div one=1>",
] {
crate::test_helpers::assert_clean(&det(), input);
}
}
#[test]
fn benign_inputs_not_detected() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"The weather today is sunny with a high of 25 degrees.",
"Please call the office at 555-1234 for assistance.",
"Welcome to our website, please enjoy your stay.",
] {
assert!(det().detect(input).is_none(), "false positive: {input}");
}
}
#[test]
fn edge_cases() {
assert!(det().detect("").is_none());
assert!(det().detect(" \t\n ").is_none());
assert!(det().detect("こんにちは世界 你好").is_none());
assert!(det().detect("script alert(1)").is_none());
assert!(det().detect("javascript alert(1)").is_none());
assert!(det().detect("vbscript msgbox(1)").is_none());
assert!(det().detect("evaluate this expression carefully").is_none());
}
#[test]
fn obfuscated_variants_detected() {
for (input, severity) in [
("<img src=x OnErRoR=alert(1)>", Severity::Critical),
("JaVaScRiPt:alert(1)", Severity::Critical),
("<SVG/onload=alert(1)>", Severity::Critical),
("<SCRIPT>alert(1)</SCRIPT>", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
}