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;
#[derive(serde::Deserialize)]
struct SchemeRules {
scheme: Vec<SchemePrefix>,
}
#[derive(serde::Deserialize)]
struct SchemePrefix {
prefix: String,
}
fn url_schemes() -> &'static [String] {
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE.get_or_init(|| {
let raw = include_str!("../rules/ssrf/schemes.toml");
let parsed: SchemeRules =
toml::from_str(raw).expect("rules/ssrf/schemes.toml must parse");
parsed.scheme.into_iter().map(|s| s.prefix).collect()
})
}
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(_) => {
nul_in_authority_salvage(payload)
}
}
}
fn nul_in_authority_salvage(payload: &str) -> bool {
let Some(authority_start) = payload.find("://") else {
return false;
};
let after_scheme = authority_start + "://".len();
let tail = &payload[after_scheme..];
let mut nul_offset: Option<usize> = None;
let bytes = tail.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0 {
nul_offset = Some(i);
break;
}
if i + 3 <= bytes.len()
&& bytes[i] == b'%'
&& bytes[i + 1] == b'0'
&& (bytes[i + 2] == b'0')
{
nul_offset = Some(i);
break;
}
i += 1;
}
let Some(off) = nul_offset else {
return false;
};
let prefix = &payload[..after_scheme + off];
let candidate = format!("{prefix}/");
let Ok(url) = url::Url::parse(&candidate) else {
return false;
};
if !url_schemes()
.iter()
.any(|s| s.trim_end_matches("://") == url.scheme())
{
return false;
}
let Some(host) = url.host_str() else {
return false;
};
let host_lc = host.to_ascii_lowercase();
let indicator_hit = ssrf_indicator_hosts()
.iter()
.any(|h| host_lc == h.to_ascii_lowercase());
let private_hit = private_ip_prefixes()
.iter()
.any(|p| host_lc.starts_with(&p.to_ascii_lowercase()));
indicator_hit || private_hit
}
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/",));
}
}