Skip to main content

waf_detection/
ssrf.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
10// ── rules ─────────────────────────────────────────────────────────────────────
11//
12// Scope (non-overlapping with RFI/LFI to avoid double-counting): SSRF detects
13// the TARGET the server is being pushed to reach — cloud-metadata endpoints,
14// loopback, private/obfuscated IPs, and SSRF-specific schemes (gopher/dict/…).
15// The generic schemes `http(s)://`/`ftp://` stay with RFI; `file://`/`php://`
16// stay with LFI. So `http://169.254.169.254/` legitimately scores from BOTH
17// RFI (rfi-remote-url, weak) and SSRF (cloud-metadata, strong): different
18// signals, not redundant.
19//
20// DECLARED intra-module overlap: 169.254.169.254 matches `ssrf-cloud-metadata`
21// (Critical) AND `ssrf-private-ip` link-local (Notice, PL3) → additive 5+2 at
22// PL3. Intentional defense-in-depth, kept explicit (see test).
23//
24// Known gaps (documented in ARCHITECTURE §8):
25//   - decimal/hex/octal obfuscation below covers only 127.0.0.1, not the
26//     metadata IP (169.254.169.254 decimal = 2852039166);
27//   - IPv6 coverage is limited to [::1] and fd00:ec2::254 (no fc00::/7, fe80::).
28
29pub static SSRF_RULES: &[Rule] = &[
30    Rule {
31        id: "ssrf-cloud-metadata",
32        // Cloud instance-metadata endpoints (AWS/GCP/Azure/Alibaba).
33        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        // SSRF-specific URL schemes (NOT http/https/ftp/file — those are RFI/LFI).
40        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        // Loopback hosts. The `127.1` short form is anchored to a host boundary
47        // (start, `/` or `@`) so it does not match valid IPs ending in `.127.1`
48        // such as 192.168.127.1.
49        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        // Decimal / hex / octal encodings of 127.0.0.1.
56        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        // RFC1918 private ranges + link-local. Noisy (legit internal refs,
63        // version-like strings), hence Notice/PL3.
64        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// ── module ────────────────────────────────────────────────────────────────────
71
72#[derive(Default)]
73pub struct SsrfModule {
74    rule_set: Option<RegexSet>,
75    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
76    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}