use crate::ascii_scan::{contains_ascii_insensitive, starts_with_ascii_insensitive};
use crate::traits::PayloadOracle;
use serde::Deserialize;
use std::sync::OnceLock;
pub struct SsrfOracle;
const URL_SCHEMES: &[&str] = &[
"http://",
"https://",
"ftp://",
"file://",
"dict://",
"gopher://",
"ldap://",
"ldaps://",
"tftp://",
"sftp://",
];
const SSRF_INDICATORS_TOML: &str = include_str!("../rules/ssrf/indicators.toml");
#[derive(Debug, Clone, Deserialize)]
struct IndicatorHost {
host: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PrivateIpPrefix {
prefix: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct InternalPath {
path: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SsrfIndicatorRules {
#[serde(default)]
indicator_host: Vec<IndicatorHost>,
#[serde(default)]
private_ip_prefix: Vec<PrivateIpPrefix>,
#[serde(default)]
internal_path: Vec<InternalPath>,
}
fn get_rules() -> &'static SsrfIndicatorRules {
static RULES: OnceLock<SsrfIndicatorRules> = OnceLock::new();
RULES.get_or_init(|| {
toml::from_str(SSRF_INDICATORS_TOML).unwrap_or_else(|_| {
SsrfIndicatorRules { indicator_host: Vec::new(), private_ip_prefix: Vec::new(), internal_path: Vec::new() }
})
})
}
fn ssrf_indicator_hosts() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.indicator_host
.iter()
.map(|h| h.host.clone())
.collect()
})
}
fn private_ip_prefixes() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.private_ip_prefix
.iter()
.map(|p| p.prefix.clone())
.collect()
})
}
fn internal_path_indicators() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
get_rules()
.internal_path
.iter()
.map(|p| p.path.clone())
.collect()
})
}
fn host_is_complete_token(payload: &str, host: &str) -> bool {
let host_bytes = host.as_bytes();
if host_bytes.is_empty() {
return false;
}
let bytes = payload.as_bytes();
let is_boundary = |b: u8| -> bool {
matches!(
b,
b'/' | b'?' | b'#' | b':' | b'@' | b'[' | b']' | b' ' | b'\t' | b'\n' | b'\r'
)
};
let mut i = 0;
while i + host_bytes.len() <= bytes.len() {
let prefix_match = bytes[i..i + host_bytes.len()]
.iter()
.zip(host_bytes.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if prefix_match {
let left_ok = i == 0
|| is_boundary(bytes[i - 1])
|| (i >= 2 && &bytes[i - 2..i] == b"//");
let right_ok = i + host_bytes.len() == bytes.len()
|| is_boundary(bytes[i + host_bytes.len()]);
if left_ok && right_ok {
return true;
}
}
i += 1;
}
false
}
fn has_ssrf_structure(payload: &str) -> bool {
if payload.starts_with("//") {
return true;
}
let has_scheme = URL_SCHEMES
.iter()
.any(|scheme| starts_with_ascii_insensitive(payload, scheme));
if !has_scheme {
return false;
}
let has_indicator_host = ssrf_indicator_hosts().iter().any(|host| {
if host.len() <= 2 {
host_is_complete_token(payload, host)
} else {
contains_ascii_insensitive(payload, host)
}
});
let has_private_ip = private_ip_prefixes().iter().any(|prefix| {
contains_ascii_insensitive(payload, prefix)
|| contains_ascii_insensitive(payload, &prefix.replace('.', "_"))
});
let has_internal_path = internal_path_indicators()
.iter()
.any(|path| contains_ascii_insensitive(payload, path));
has_indicator_host || has_private_ip || has_internal_path
}
const MAX_URL_PARSE_BYTES: usize = 16 * 1024 * 1024;
fn has_valid_url_syntax(payload: &str) -> bool {
let payload = payload.trim_end_matches(['\0', '\u{FFFD}']);
if payload.starts_with("//") {
return true;
}
if payload.len() > MAX_URL_PARSE_BYTES {
return false;
}
match url::Url::parse(payload) {
Ok(url) => {
let scheme_ok = URL_SCHEMES
.iter()
.any(|s| s.trim_end_matches("://") == url.scheme());
if !scheme_ok {
return false;
}
if url.scheme() == "file" {
return true;
}
true
}
Err(_) => {
false
}
}
}
impl PayloadOracle for SsrfOracle {
fn is_semantically_valid(&self, _original: &str, transformed: &str) -> bool {
if transformed.trim().is_empty() {
return false;
}
if !has_ssrf_structure(transformed) {
return false;
}
has_valid_url_syntax(transformed)
}
fn name(&self) -> &'static str {
"SSRF"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn localhost_http_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://localhost/admin", "http://localhost/admin",));
}
#[test]
fn loopback_ip_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://127.0.0.1/", "http://127.0.0.1/",));
}
#[test]
fn https_localhost_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("https://localhost:8443/", "https://localhost:8443/",)
);
}
#[test]
fn aws_metadata_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid(
"http://169.254.169.254/latest/meta-data/",
"http://169.254.169.254/latest/meta-data/",
));
}
#[test]
fn gcp_metadata_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid(
"http://metadata.google.internal/",
"http://metadata.google.internal/",
));
}
#[test]
fn ipv6_loopback_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://[::1]/admin", "http://[::1]/admin",));
}
#[test]
fn ipv4_mapped_ipv6_valid() {
let oracle = SsrfOracle;
assert!(
oracle
.is_semantically_valid("http://[::ffff:127.0.0.1]/", "http://[::ffff:127.0.0.1]/",)
);
}
#[test]
fn private_ip_10_x_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("http://10.0.0.1/internal", "http://10.0.0.1/internal",)
);
}
#[test]
fn private_ip_192_168_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://192.168.1.1/", "http://192.168.1.1/",));
}
#[test]
fn ip_integer_encoding_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://2130706433/", "http://2130706433/",));
}
#[test]
fn ip_octal_encoding_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://0177.0.0.1/", "http://0177.0.0.1/",));
}
#[test]
fn protocol_relative_url_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("//localhost/admin", "//localhost/admin",));
}
#[test]
fn file_scheme_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("file:///etc/passwd", "file:///etc/passwd",));
}
#[test]
fn dict_scheme_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("dict://localhost:11211/", "dict://localhost:11211/",)
);
}
#[test]
fn gopher_scheme_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("gopher://localhost:9001/", "gopher://localhost:9001/",)
);
}
#[test]
fn internal_api_path_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid(
"http://127.0.0.1/api/v1/users",
"http://127.0.0.1/api/v1/users",
));
}
#[test]
fn internal_admin_path_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://localhost/admin", "http://localhost/admin",));
}
#[test]
fn empty_string_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", ""));
}
#[test]
fn plain_text_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", "hello world"));
}
#[test]
fn url_without_scheme_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", "127.0.0.1/admin",));
}
#[test]
fn public_url_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", "http://example.com/",));
}
#[test]
fn destroyed_scheme_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", "%68%74%74%70://127.0.0.1/",));
}
#[test]
fn alibaba_metadata_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid(
"http://100.100.100.200/latest/meta-data/",
"http://100.100.100.200/latest/meta-data/",
));
}
#[test]
fn oracle_metadata_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://192.0.0.1/", "http://192.0.0.1/",));
}
#[test]
fn zero_ip_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://0/", "http://0/",));
}
#[test]
fn short_loopback_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://127.1/", "http://127.1/",));
}
#[test]
fn ldap_scheme_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("ldap://localhost:389/", "ldap://localhost:389/",));
}
#[test]
fn url_with_query_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid(
"http://127.0.0.1/api?action=read",
"http://127.0.0.1/api?action=read",
));
}
#[test]
fn url_with_fragment_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("http://127.0.0.1/#section", "http://127.0.0.1/#section",)
);
}
#[test]
fn userinfo_in_url_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid(
"http://user:pass@127.0.0.1/",
"http://user:pass@127.0.0.1/",
)
);
}
#[test]
fn nip_io_domain_valid() {
let oracle = SsrfOracle;
assert!(
oracle.is_semantically_valid("http://127.0.0.1.nip.io/", "http://127.0.0.1.nip.io/",)
);
}
#[test]
fn adversarial_unicode_host() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid(
"http://127.0.0.1/",
"http://localhost/", ));
}
#[test]
fn adversarial_null_byte() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("http://127.0.0.1/", "http://127.0.0.1/\x00",));
}
#[test]
fn oracle_name_is_ssrf() {
let oracle = SsrfOracle;
assert_eq!(oracle.name(), "SSRF");
}
#[test]
fn scheme_only_invalid() {
let oracle = SsrfOracle;
assert!(!oracle.is_semantically_valid("http://127.0.0.1/", "http://",));
}
#[test]
fn short_indicator_requires_token_boundary() {
assert!(host_is_complete_token("http://0/", "0"));
assert!(host_is_complete_token("0", "0"));
assert!(!host_is_complete_token("/page?id=100", "0"));
assert!(!host_is_complete_token("a0b", "0"));
assert!(!host_is_complete_token("abc::def", "::"));
}
#[test]
fn ftp_scheme_private_ip_valid() {
let oracle = SsrfOracle;
assert!(oracle.is_semantically_valid("ftp://192.168.1.1/", "ftp://192.168.1.1/",));
}
}