Skip to main content

waf_detection/
ssti.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, inspectable_header_values, Rule};
9
10// ── rules (Fase 10a) ────────────────────────────────────────────────────────────
11//
12// Server-Side Template Injection: a template-engine expression injected into a
13// user-controlled value and evaluated server-side (RCE / data exfiltration). The
14// discriminator vs benign text is the TEMPLATE DELIMITER carrying an evaluatable
15// payload, not a bare `{{ }}` (which legit client templates / mustache content use):
16//   - arithmetic inside a delimiter (`{{1337*1338}}`, `#{16*8787}`, `${7*7}`) — the
17//     classic polyglot probe; a digit·operator·digit immediately inside the delimiter
18//     is never benign user input;
19//   - FreeMarker directives (`<#assign …>`, `?new()`, `freemarker.template…`) — engine
20//     syntax that only a template injection produces.
21// Unequivocal template syntax → Critical, block-alone (decision 3). Patterns target
22// the normalizer OUTPUT (Fase 2): query/body values are double percent-decoded +
23// NFKC-normalized; every token here is ASCII, NFKC-stable.
24//
25// Fase 10a covers the URL/Plain-encoded form; the Base64Flat duplicates are 10c-scope
26// (need base64-decode in §6, invariant here).
27
28pub static SSTI_RULES: &[Rule] = &[
29    Rule {
30        id: "ssti-template-arithmetic",
31        // A template delimiter (`{{`, `#{`, `${`) immediately followed by an
32        // arithmetic expression `<digits> <op> <digits>`. Matches `{{1337*1338}}`,
33        // `#{16*8787}`, `${7*7}`; a benign `{{ user.name }}` or `${base}` has no
34        // digit-operator-digit and is not flagged.
35        pattern: r"(?:\{\{|[#$]\{)\s*\d+\s*[*+/x-]\s*\d+",
36        severity: Severity::Critical,
37        paranoia: 1,
38    },
39    Rule {
40        id: "ssti-freemarker-directive",
41        // FreeMarker directive / built-in syntax: `<#assign|list|if|…>`, the
42        // instantiation built-in `?new()`, or the FQN of the template utility classes.
43        // Matches `<#assign ex = "freemarker.template.utility.Execute"?new()>`.
44        pattern: r"(?i)<#(?:assign|list|if|include|macro|function|global|setting)\b|\?new\(\)|freemarker\.template",
45        severity: Severity::Critical,
46        paranoia: 1,
47    },
48];
49
50// ── module ──────────────────────────────────────────────────────────────────────
51
52#[derive(Default)]
53pub struct SstiModule {
54    rule_set: Option<RegexSet>,
55    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
56    active_rules: Vec<&'static Rule>,
57}
58
59impl SstiModule {
60    pub fn new() -> Self {
61        Self::default()
62    }
63}
64
65impl WafModule for SstiModule {
66    fn id(&self) -> &str {
67        "ssti"
68    }
69
70    fn phase(&self) -> Phase {
71        Phase::Body
72    }
73
74    fn init(&mut self, cfg: &Config) {
75        let pl = cfg.waf.paranoia_level;
76        self.active_rules = SSTI_RULES.iter().filter(|r| r.paranoia <= pl).collect();
77        self.rule_set = Some(
78            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
79                .expect("SSTI rule compilation failed — check patterns at startup"),
80        );
81    }
82
83    fn inspect(&self, ctx: &RequestContext) -> Decision {
84        let Some(rule_set) = &self.rule_set else {
85            return Decision::Allow;
86        };
87
88        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
89        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
90        let body_vals = body_str_values(&ctx.normalized.body);
91        let body = body_vals.iter().map(String::as_str);
92        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
93
94        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
95        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
96        // path-placed payload bypasses. The prefilter already scans the path (sound).
97        let path = std::iter::once(ctx.normalized.path.as_str());
98        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
99        let headers = inspectable_header_values(ctx);
100        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
101        if matched.is_empty() {
102            return Decision::Allow;
103        }
104
105        let items: Vec<ScoreItem> = matched
106            .iter()
107            .map(|&idx| {
108                let rule = self.active_rules[idx];
109                warn!(
110                    request_id = %ctx.request_id,
111                    rule_id = %rule.id,
112                    severity = ?rule.severity,
113                    "ssti detection"
114                );
115                ScoreItem {
116                    rule_id: rule.id.to_string(),
117                    severity: rule.severity,
118                }
119            })
120            .collect();
121
122        Decision::Scores(items)
123    }
124}