use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?i)169\.254\.169\.254",
r"|metadata\.google\.internal",
r"|//10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
r"|//172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}",
r"|//192\.168\.\d{1,3}\.\d{1,3}",
r"|//127\.\d{1,3}(?:\.\d{1,3}){0,2}",
r"|//localhost\b",
r"|//0\.0\.0\.0",
r"|//\[::1\]",
r"|gopher://",
r"|dict://",
r"|ftp://[^/]*@",
r"|file:///",
]
.concat(),
)
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?i)10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
r"|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}",
r"|192\.168\.\d{1,3}\.\d{1,3}",
r"|127\.\d{1,3}(?:\.\d{1,3}){0,2}",
r"|0\.0\.0\.0",
r"|\[::1\]",
]
.concat(),
)
.unwrap()
});
pub struct SsrfDetector;
impl Detector for SsrfDetector {
fn name(&self) -> &'static str {
"ssrf"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::Critical,
"SSRF server-side request forgery detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::Low,
"internal address literal present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_detected(input: &str) {
crate::test_helpers::assert_detected(
&SsrfDetector,
input,
AttackCategory::Protocol,
Severity::Critical,
);
}
fn assert_clean(input: &str) {
crate::test_helpers::assert_clean(&SsrfDetector, input);
}
fn assert_low(input: &str) {
crate::test_helpers::assert_detected(
&SsrfDetector,
input,
AttackCategory::Protocol,
Severity::Low,
);
}
#[test]
fn name_is_ssrf() {
assert_eq!(SsrfDetector.name(), "ssrf");
}
#[test]
fn detects_cloud_metadata_ip() {
assert_detected("http://169.254.169.254/latest/meta-data/");
}
#[test]
fn detects_internal_ipv4() {
assert_detected("http://10.0.0.1/admin");
}
#[test]
fn detects_private_ranges() {
assert_detected("http://192.168.1.1/");
assert_detected("http://172.16.0.1/");
assert_detected("http://172.31.255.255/");
assert_detected("http://127.0.0.1:8080/");
assert_detected("http://0.0.0.0/");
assert_detected("http://[::1]/");
}
#[test]
fn detects_ssrf_uri_schemes() {
assert_detected("gopher://evil.com/_GET / HTTP/1.1");
assert_detected("dict://evil.com:11211/");
assert_detected("ftp://user@evil.com/file");
assert_detected("file:///etc/passwd");
}
#[test]
fn detects_mixed_case_schemes() {
assert_detected("GOPHER://evil.com/");
assert_detected("FILE:///etc/shadow");
}
#[test]
fn detects_loopback_hostname_and_short_forms() {
assert_detected("http://localhost/admin");
assert_detected("http://localhost:8080/");
assert_detected("http://127.1/");
assert_detected(r#"{"url": "http://localhost"}"#);
}
#[test]
fn detects_cloud_metadata_hostname() {
assert_detected("http://metadata.google.internal/computeMetadata/v1/");
}
#[test]
fn rejects_public_hosts() {
assert_clean("http://example.com/index.html");
assert_clean("https://github.com/security-rust");
assert_clean("http://172.32.0.1/");
assert_clean("http://8.8.8.8/dns");
}
#[test]
fn bare_internal_literals_are_weak_not_critical() {
for input in [
"GET /api HTTP/1.1\r\nHost: api.internal\r\nX-Forwarded-For: 10.0.0.5\r\nX-Real-IP: 10.0.0.5\r\n\r\n",
"GET /healthz HTTP/1.1\r\nHost: 10.244.1.5:8080\r\n\r\n",
"POST /v1/items HTTP/1.1\r\nHost: 172.18.0.2:3000\r\n\r\n",
"GET / HTTP/1.1\r\nHost: 192.168.1.1\r\n\r\n",
"server {\n listen 0.0.0.0:80;\n}\n",
"server {\n listen [::1]:8080;\n}\n",
"if __name__ == \"__main__\":\n app.run(host=\"0.0.0.0\", port=5000)\n",
"bind 127.0.0.1 -::1\nprotected-mode yes\n",
r#"{"host": "10.0.0.1", "port": 6379}"#,
"postgres://app:pw@10.0.0.5:5432/db",
"http://user:pass@127.0.0.1/",
] {
assert_low(input);
}
}
#[test]
fn url_context_internal_targets_stay_critical() {
for input in [
"curl http://10.0.0.1:8080/admin",
"http://192.168.0.10/cgi-bin/status",
"http://172.20.0.3:9200/_cat/indices",
"http://127.1/",
"http://[::1]:9200/",
"http://0.0.0.0:8080/",
"gopher://10.0.0.1:6379/_INFO",
] {
assert_detected(input);
}
}
#[test]
fn rejects_localhost_lookalikes() {
assert_clean("mongodb://admin:password@localhost:27017/db");
assert_clean("http://internal.example.com/api");
assert_clean("metadata.google.com 是普通域名");
assert_clean("localhost 只是文档里的一个词");
}
#[test]
fn rejects_near_misses() {
assert_clean("http://10.0.0/admin");
assert_clean("http://169.254.169/admin");
assert_clean("ftp://evil.com/pub");
assert_clean("http://192.168/admin");
}
#[test]
fn rejects_empty_and_whitespace() {
assert_clean("");
assert_clean(" ");
assert_clean("\t\n");
}
#[test]
fn rejects_unicode_text() {
assert_clean("你好,世界!这是一个正常的中文文本。");
}
}