security_rust/injection/
ssti.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
14 vec![
15 Regex::new(r"\{\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*\d+[^{}]{0,120}?\}\}").unwrap(),
18 Regex::new(r"\$\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*\d+[^{}]{0,120}?\}").unwrap(),
19 Regex::new(r"\{\{[ \t]*config\b").unwrap(),
23 Regex::new(r"\$\{[ \t]*(?:T[ \t]*\(|@[\w.]+@)").unwrap(),
25 Regex::new(r"\{%\s*.*?\s*%\}").unwrap(),
27 Regex::new(r"<%=").unwrap(),
28 Regex::new(r"<%@").unwrap(),
29 Regex::new(r"#set\s*\(").unwrap(),
30 Regex::new(r"__mro__").unwrap(),
31 Regex::new(r"__subclasses__").unwrap(),
32 Regex::new(r"__globals__").unwrap(),
33 Regex::new(r"__builtins__").unwrap(),
34 Regex::new(r"__class__").unwrap(),
35 Regex::new(r"__dict__").unwrap(),
36 ]
37});
38
39pub struct SstiDetector;
40
41impl Detector for SstiDetector {
42 fn name(&self) -> &'static str {
43 "ssti"
44 }
45
46 fn detect(&self, input: &str) -> Option<DetectionResult> {
47 regex_detect(
48 &PATTERNS,
49 self.name(),
50 AttackCategory::Injection,
51 Severity::Critical,
52 "Server-Side Template Injection detected",
53 input,
54 )
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 fn det() -> SstiDetector {
63 SstiDetector
64 }
65
66 fn assert_hit(input: &str) {
67 crate::test_helpers::assert_detected(
68 &det(),
69 input,
70 AttackCategory::Injection,
71 Severity::Critical,
72 );
73 }
74
75 #[test]
76 fn name_is_ssti() {
77 assert_eq!(det().name(), "ssti");
78 }
79
80 #[test]
81 fn detects_common_payloads() {
82 for input in [
83 "{{7*7}}",
84 "{{ ''.__class__.__mro__[1].__subclasses__() }}",
85 "${7*7}",
86 "{% include '/etc/passwd' %}",
87 "<%= params[:x] %>",
88 r#"<%@ page import="java.util.*" %>"#,
89 "#set($x = 5)",
90 "{{config.__init__.__globals__}}",
91 ] {
92 assert_hit(input);
93 }
94 }
95
96 #[test]
97 fn benign_inputs_not_detected() {
98 for input in [
99 "Hello, this is a normal text input. Nothing suspicious here.",
100 "The total is $5.00 plus tax",
101 "Please enter your name below",
102 "The class of 2026 graduates in May",
103 "100% of users agree with this",
104 ] {
105 assert!(det().detect(input).is_none(), "false positive: {input}");
106 }
107 }
108
109 #[test]
111 fn plain_interpolation_not_detected() {
112 for input in [
113 "the price is ${amount}",
114 "${user}${pass}",
115 "@Value(\"${x.y.z}\")",
116 "${env:JAVA_HOME}",
117 "const x = `${name}`",
118 "${#strings.toUpperCase(name)}",
119 "{{ name }}",
120 "{{ app_config }}",
121 "${timeout:30s}",
122 "${PATH:-/usr/bin}",
123 ] {
124 assert!(det().detect(input).is_none(), "false positive: {input}");
125 }
126 }
127
128 #[test]
130 fn evaluation_probes_and_runtime_access_detected() {
131 for input in [
132 "${7*7}",
133 "{{7*7}}",
134 "{{ 7 * 7 }}",
135 "${{7*7}}",
136 "<%= 7*7 %>",
137 "{{config}}",
138 "{{ config.items }}",
139 "${T(java.lang.Runtime)}",
140 "${@java.lang.Runtime@getRuntime()}",
141 "{{ ''.__class__.__mro__ }}",
142 "{{ self.__dict__ }}",
143 ] {
144 assert_hit(input);
145 }
146 }
147
148 #[test]
149 fn edge_cases() {
150 assert!(det().detect("").is_none());
151 assert!(det().detect(" \t\n ").is_none());
152 assert!(det().detect("你好世界 こんにちは").is_none());
153 assert!(det().detect("{7*7}").is_none());
155 assert!(det().detect("{{7*7").is_none());
156 assert!(det().detect("__CLASS__").is_none());
157 assert!(det().detect("{$x=7}").is_none());
158 }
159
160 #[test]
161 fn obfuscated_variants_detected() {
162 for input in [
163 "{{ ''.__class__.__MRO__[1] }}",
164 "{{ self.__dict__ }}",
165 "{{request.application.__globals__}}",
166 ] {
167 assert_hit(input);
168 }
169 }
170}