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]
145 fn template_source_is_low_not_critical() {
146 for input in [
147 "{% if user.is_admin %}\n <p>Admin</p>\n{% endif %}",
148 "{% for item in items %}\n <li>{{ item.name }}</li>\n{% endfor %}",
149 "<h1>Hello <%= @user.name %></h1>",
150 r#"<%@ page language="java" contentType="text/html; charset=UTF-8" %>"#,
151 "#set($x = 5)",
152 "<#assign title=\"Home\">",
153 ] {
154 assert_hit_at(input, Severity::Low);
155 }
156 }
157
158 #[test]
161 fn bare_python_dunders_are_low_but_delimited_ones_stay_critical() {
162 for input in [
163 "return f\"<Store {self.__class__.__name__}>\"",
164 "return dict(self.__dict__)",
165 "return [c for c in Plugin.__subclasses__()]",
166 ] {
167 assert_hit_at(input, Severity::Low);
168 }
169 for input in [
170 "{{ self.__dict__ }}",
171 "{{ ''.__class__.__mro__ }}",
172 "${config.__class__}",
173 ] {
174 assert_hit(input);
175 }
176 }
177
178 #[test]
181 fn python_protocol_methods_not_detected() {
182 for input in [
183 "def __getitem__(self, key):\n return self._data[key]",
184 "__set_name__ is called by the descriptor protocol.",
185 "def __getattr__(self, name):\n raise AttributeError(name)",
186 ] {
187 crate::test_helpers::assert_clean(&det(), input);
188 }
189 }
190
191 #[test]
192 fn benign_inputs_not_detected() {
193 for input in [
194 "Hello, this is a normal text input. Nothing suspicious here.",
195 "The total is $5.00 plus tax",
196 "Please enter your name below",
197 "The class of 2026 graduates in May",
198 "100% of users agree with this",
199 ] {
200 assert!(det().detect(input).is_none(), "false positive: {input}");
201 }
202 }
203
204 #[test]
206 fn plain_interpolation_not_detected() {
207 for input in [
208 "the price is ${amount}",
209 "${user}${pass}",
210 "@Value(\"${x.y.z}\")",
211 "${env:JAVA_HOME}",
212 "const x = `${name}`",
213 "${#strings.toUpperCase(name)}",
214 "{{ name }}",
215 "{{ app_config }}",
216 "${timeout:30s}",
217 "${PATH:-/usr/bin}",
218 ] {
219 assert!(det().detect(input).is_none(), "false positive: {input}");
220 }
221 }
222
223 #[test]
225 fn evaluation_probes_and_runtime_access_detected() {
226 for input in [
227 "${7*7}",
228 "{{7*7}}",
229 "{{ 7 * 7 }}",
230 "{{ 10 % 2 }}",
232 "total = {{ 10 % 2 }}",
233 "${{7*7}}",
234 "<%= 7*7 %>",
235 "{{config}}",
236 "{{ config.items }}",
237 "${T(java.lang.Runtime)}",
238 "${@java.lang.Runtime@getRuntime()}",
239 "{{ ''.__class__.__mro__ }}",
240 "{{ self.__dict__ }}",
241 ] {
242 assert_hit(input);
243 }
244 }
245
246 #[test]
248 fn freemarker_and_quoted_operand_detected() {
249 for input in [
250 "{{7*'7'}}",
251 r#"{{7*"7"}}"#,
252 "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"id\")}",
253 r#"${"freemarker.template.utility.Execute"?new()("id")}"#,
254 ] {
255 assert_hit(input);
256 }
257 }
258
259 #[test]
261 fn assign_and_new_lookalikes_not_detected() {
262 for input in [
263 r##"<a href="#assign something">x</a>"##,
264 r##"<a href="#assign">x</a>"##,
265 "https://example.com/#assign-section",
266 "q?new=1", "{{ '7' }}", r#"{{ "hello" * 3 }}"#, "{{ count % 2 }}",
272 "{{ n % 10 }}",
273 "{{ a % b }}",
274 "The template renders the remainder of {{ a % b }} on the page.",
275 ] {
276 assert!(det().detect(input).is_none(), "false positive: {input}");
277 }
278 }
279
280 #[test]
281 fn edge_cases() {
282 assert!(det().detect("").is_none());
283 assert!(det().detect(" \t\n ").is_none());
284 assert!(det().detect("你好世界 こんにちは").is_none());
285 assert!(det().detect("{7*7}").is_none());
287 assert!(det().detect("{{7*7").is_none());
288 assert!(det().detect("__CLASS__").is_none());
289 assert!(det().detect("{$x=7}").is_none());
290 }
291
292 #[test]
293 fn obfuscated_variants_detected() {
294 for input in [
295 "{{ ''.__class__.__MRO__[1] }}",
296 "{{ self.__dict__ }}",
297 "{{request.application.__globals__}}",
298 ] {
299 assert_hit(input);
300 }
301 }
302}
303