1use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, Rule};
9
10pub static SSRF_RULES: &[Rule] = &[
30 Rule {
31 id: "ssrf-cloud-metadata",
32 pattern: r"(?i)(?:169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200|fd00:ec2::254)",
34 severity: Severity::Critical,
35 paranoia: 1,
36 },
37 Rule {
38 id: "ssrf-dangerous-scheme",
39 pattern: r"(?i)(?:gopher|dict|ldap|tftp|sftp|redis|jar|netdoc)://",
41 severity: Severity::Critical,
42 paranoia: 1,
43 },
44 Rule {
45 id: "ssrf-loopback",
46 pattern: r"(?i)(?:\b(?:127\.0\.0\.1|0\.0\.0\.0|localhost|\[::1\])\b|(?:[/@]|\A)127\.1(?:[:/]|\z))",
50 severity: Severity::Warning,
51 paranoia: 2,
52 },
53 Rule {
54 id: "ssrf-ip-obfuscation",
55 pattern: r"(?i)(?:0x7f[0-9a-f]{6}|\b2130706433\b|\b017[0-7]\.0\.0\.0?1)",
57 severity: Severity::Warning,
58 paranoia: 2,
59 },
60 Rule {
61 id: "ssrf-private-ip",
62 pattern: r"(?i)\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3})\b",
65 severity: Severity::Notice,
66 paranoia: 3,
67 },
68];
69
70#[derive(Default)]
73pub struct SsrfModule {
74 rule_set: Option<RegexSet>,
75 active_rules: Vec<&'static Rule>,
77}
78
79impl SsrfModule {
80 pub fn new() -> Self {
81 Self::default()
82 }
83}
84
85impl WafModule for SsrfModule {
86 fn id(&self) -> &str {
87 "ssrf"
88 }
89
90 fn phase(&self) -> Phase {
91 Phase::Body
92 }
93
94 fn init(&mut self, cfg: &Config) {
95 let pl = cfg.waf.paranoia_level;
96 self.active_rules = SSRF_RULES.iter().filter(|r| r.paranoia <= pl).collect();
97 self.rule_set = Some(
98 RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
99 .expect("SSRF rule compilation failed — check patterns at startup"),
100 );
101 }
102
103 fn inspect(&self, ctx: &RequestContext) -> Decision {
104 let Some(rule_set) = &self.rule_set else {
105 return Decision::Allow;
106 };
107
108 let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
109 let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
110 let body_vals = body_str_values(&ctx.normalized.body);
111 let body = body_vals.iter().map(String::as_str);
112 let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
113
114 let matched = all_matches(rule_set, query.chain(cookies).chain(body).chain(derived));
115 if matched.is_empty() {
116 return Decision::Allow;
117 }
118
119 let items: Vec<ScoreItem> = matched
120 .iter()
121 .map(|&idx| {
122 let rule = self.active_rules[idx];
123 warn!(
124 request_id = %ctx.request_id,
125 rule_id = %rule.id,
126 severity = ?rule.severity,
127 "ssrf detection"
128 );
129 ScoreItem {
130 rule_id: rule.id.to_string(),
131 severity: rule.severity,
132 }
133 })
134 .collect();
135
136 Decision::Scores(items)
137 }
138}