1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
25 Regex::new(concat!(
26 r#"\{\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?\}\}"#,
31 r#"|\$\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?\}"#,
32 r#"|<%=[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?%>"#,
34 r"|\{\{[ \t]*config\b",
38 r"|\$\{[ \t]*(?:T[ \t]*\(|@[\w.]+@)",
40 r#"|\{%\s*(?:include|extends|import|from)\s+['"]\s*(?:/|\.\.)"#,
43 r"|\{\{[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__|\{%[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__|\$\{[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__",
47 r"|(?i:\?new\s*\()",
50 ))
51 .unwrap()
52});
53
54static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
62 Regex::new(concat!(
63 r"\{%\s*.*?\s*%\}",
64 r"|<%=",
65 r"|<%@",
66 r"|#set\s*\(",
67 r"|(?i:<#assign\s)",
69 r"|__(?:class|dict|mro|subclasses|globals|builtins)__",
70 ))
71 .unwrap()
72});
73
74pub struct SstiDetector;
75
76impl Detector for SstiDetector {
77 fn name(&self) -> &'static str {
78 "ssti"
79 }
80
81 fn detect(&self, input: &str) -> Option<DetectionResult> {
82 regex_detect(
83 std::slice::from_ref(&*STRONG_PATTERNS),
84 self.name(),
85 AttackCategory::Injection,
86 Severity::Critical,
87 "Server-Side Template Injection detected",
88 input,
89 )
90 .or_else(|| {
91 regex_detect(
92 std::slice::from_ref(&*WEAK_PATTERNS),
93 self.name(),
94 AttackCategory::Injection,
95 Severity::Low,
96 "Template directive / magic attribute present (weak signal)",
97 input,
98 )
99 })
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn det() -> SstiDetector {
108 SstiDetector
109 }
110
111 fn assert_hit(input: &str) {
112 assert_hit_at(input, Severity::Critical);
113 }
114
115 fn assert_hit_at(input: &str, severity: Severity) {
116 crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
117 }
118
119 #[test]
120 fn name_is_ssti() {
121 assert_eq!(det().name(), "ssti");
122 }
123
124 #[test]
125 fn detects_common_payloads() {
126 for (input, severity) in [
127 ("{{7*7}}", Severity::Critical),
129 ("{{ ''.__class__.__mro__[1].__subclasses__() }}", Severity::Critical),
130 ("${7*7}", Severity::Critical),
131 ("{% include '/etc/passwd' %}", Severity::Critical),
132 ("{{config.__init__.__globals__}}", Severity::Critical),
133 ("<%= params[:x] %>", Severity::Low),
135 (r#"<%@ page import="java.util.*" %>"#, Severity::Low),
136 ("#set($x = 5)", Severity::Low),
137 ] {
138 assert_hit_at(input, severity);
139 }
140 }
141
142 #[test]
148 fn template_source_is_low_not_critical() {
149 for input in [
150 "{% if user.is_admin %}\n <p>Admin</p>\n{% endif %}",
151 "{% for item in items %}\n <li>{{ item.name }}</li>\n{% endfor %}",
152 "<h1>Hello <%= @user.name %></h1>",
153 r#"<%@ page language="java" contentType="text/html; charset=UTF-8" %>"#,
154 "#set($x = 5)",
155 "<#assign title=\"Home\">",
156 ] {
157 assert_hit_at(input, Severity::Low);
158 }
159 }
160
161 #[test]
164 fn bare_python_dunders_are_low_but_delimited_ones_stay_critical() {
165 for input in [
166 "return f\"<Store {self.__class__.__name__}>\"",
167 "return dict(self.__dict__)",
168 "return [c for c in Plugin.__subclasses__()]",
169 ] {
170 assert_hit_at(input, Severity::Low);
171 }
172 for input in [
173 "{{ self.__dict__ }}",
174 "{{ ''.__class__.__mro__ }}",
175 "${config.__class__}",
176 ] {
177 assert_hit(input);
178 }
179 }
180
181 #[test]
184 fn python_protocol_methods_not_detected() {
185 for input in [
186 "def __getitem__(self, key):\n return self._data[key]",
187 "__set_name__ is called by the descriptor protocol.",
188 "def __getattr__(self, name):\n raise AttributeError(name)",
189 ] {
190 crate::test_helpers::assert_clean(&det(), input);
191 }
192 }
193
194 #[test]
195 fn benign_inputs_not_detected() {
196 for input in [
197 "Hello, this is a normal text input. Nothing suspicious here.",
198 "The total is $5.00 plus tax",
199 "Please enter your name below",
200 "The class of 2026 graduates in May",
201 "100% of users agree with this",
202 ] {
203 assert!(det().detect(input).is_none(), "false positive: {input}");
204 }
205 }
206
207 #[test]
209 fn plain_interpolation_not_detected() {
210 for input in [
211 "the price is ${amount}",
212 "${user}${pass}",
213 "@Value(\"${x.y.z}\")",
214 "${env:JAVA_HOME}",
215 "const x = `${name}`",
216 "${#strings.toUpperCase(name)}",
217 "{{ name }}",
218 "{{ app_config }}",
219 "${timeout:30s}",
220 "${PATH:-/usr/bin}",
221 ] {
222 assert!(det().detect(input).is_none(), "false positive: {input}");
223 }
224 }
225
226 #[test]
228 fn evaluation_probes_and_runtime_access_detected() {
229 for input in [
230 "${7*7}",
231 "{{7*7}}",
232 "{{ 7 * 7 }}",
233 "{{ 10 % 2 }}",
235 "total = {{ 10 % 2 }}",
236 "${{7*7}}",
237 "<%= 7*7 %>",
238 "{{config}}",
239 "{{ config.items }}",
240 "${T(java.lang.Runtime)}",
241 "${@java.lang.Runtime@getRuntime()}",
242 "{{ ''.__class__.__mro__ }}",
243 "{{ self.__dict__ }}",
244 ] {
245 assert_hit(input);
246 }
247 }
248
249 #[test]
251 fn freemarker_and_quoted_operand_detected() {
252 for input in [
253 "{{7*'7'}}",
254 r#"{{7*"7"}}"#,
255 "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"id\")}",
256 r#"${"freemarker.template.utility.Execute"?new()("id")}"#,
257 ] {
258 assert_hit(input);
259 }
260 }
261
262 #[test]
264 fn assign_and_new_lookalikes_not_detected() {
265 for input in [
266 r##"<a href="#assign something">x</a>"##,
267 r##"<a href="#assign">x</a>"##,
268 "https://example.com/#assign-section",
269 "q?new=1", "{{ '7' }}", r#"{{ "hello" * 3 }}"#, "{{ count % 2 }}",
275 "{{ n % 10 }}",
276 "{{ a % b }}",
277 "The template renders the remainder of {{ a % b }} on the page.",
278 ] {
279 assert!(det().detect(input).is_none(), "false positive: {input}");
280 }
281 }
282
283 #[test]
284 fn edge_cases() {
285 assert!(det().detect("").is_none());
286 assert!(det().detect(" \t\n ").is_none());
287 assert!(det().detect("你好世界 こんにちは").is_none());
288 assert!(det().detect("{7*7}").is_none());
290 assert!(det().detect("{{7*7").is_none());
291 assert!(det().detect("__CLASS__").is_none());
292 assert!(det().detect("{$x=7}").is_none());
293 }
294
295 #[test]
296 fn obfuscated_variants_detected() {
297 for input in [
298 "{{ ''.__class__.__MRO__[1] }}",
299 "{{ self.__dict__ }}",
300 "{{request.application.__globals__}}",
301 ] {
302 assert_hit(input);
303 }
304 }
305}
306