bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! Server-Side Template Injection (SSTI) detection.
//!
//! Detects template expression patterns for common engines: Jinja2/Twig
//! (`{{...}}`), ERB (`<%=...%>`), Freemarker/Velocity (`${...}`), and
//! common SSTI probe payloads like `{{7*7}}`.

use std::sync::OnceLock;

use regex::RegexSet;

static SSTI_PATTERNS: OnceLock<RegexSet> = OnceLock::new();

static SSTI_DESCRIPTIONS: &[&str] = &[
    "SSTI: Jinja2/Twig expression ({{ }})",
    "SSTI: ERB expression (<%= %>)",
    "SSTI: arithmetic probe ({{7*7}}, ${7*7})",
    "SSTI: class/module access (__class__, __mro__)",
    "SSTI: Jinja2 import/popen pattern",
    "SSTI: Freemarker built-in (.exec, .new)",
];

fn patterns() -> &'static RegexSet {
    SSTI_PATTERNS.get_or_init(|| {
        RegexSet::new([
            // 0: Jinja2/Twig double-brace with code-like content
            r"\{\{[^}]*(config|request|self|class|import|popen|subprocess|os\.|system|eval|exec|globals|builtins|lipsum)",
            // 1: ERB expression tags
            r"<%[=-]?\s*(system|exec|eval|IO\.|File\.|require|`)",
            // 2: Arithmetic probe (classic SSTI detection: {{7*7}}, ${7*7}, #{7*7})
            r"(\{\{|\$\{|#\{)\d+\s*\*\s*\d+\s*(\}\}|\})",
            // 3: Python dunder access via template
            r"(?i)(__class__|__mro__|__subclasses__|__builtins__|__globals__|__import__)",
            // 4: Jinja2 import / popen patterns
            r"(?i)\{\{[^}]*(import\s*\(|popen|subprocess|os\.system)",
            // 5: Freemarker built-ins (.exec(), .new())
            r"(?i)\$\{[^}]*\.(exec|new|getClass|forName)\s*\(",
        ])
        .expect("SSTI regex patterns must compile")
    })
}

/// Check an input string for SSTI patterns.
pub fn check_ssti(input: &str) -> Option<String> {
    let set = patterns();
    let matches: Vec<_> = set.matches(input).into_iter().collect();
    if matches.is_empty() {
        None
    } else {
        let idx = matches[0];
        Some(
            SSTI_DESCRIPTIONS
                .get(idx)
                .unwrap_or(&"template injection")
                .to_string(),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_jinja2_config() {
        assert!(check_ssti("{{config}}").is_some());
        assert!(check_ssti("{{request.application.__globals__}}").is_some());
    }

    #[test]
    fn detects_arithmetic_probe() {
        assert!(check_ssti("{{7*7}}").is_some());
        assert!(check_ssti("${7*7}").is_some());
        assert!(check_ssti("#{7*7}").is_some());
    }

    #[test]
    fn detects_dunder_access() {
        assert!(check_ssti("''.__class__.__mro__").is_some());
        assert!(check_ssti("__builtins__.__import__('os')").is_some());
    }

    #[test]
    fn detects_erb() {
        assert!(check_ssti("<%= system('id') %>").is_some());
        assert!(check_ssti("<%= `whoami` %>").is_some());
    }

    #[test]
    fn detects_freemarker() {
        assert!(check_ssti("${\"freemarker\".exec('id')}").is_some());
    }

    #[test]
    fn allows_normal_text() {
        assert!(check_ssti("Hello world").is_none());
        assert!(check_ssti("/api/users/123").is_none());
    }

    #[test]
    fn allows_normal_json() {
        assert!(check_ssti(r#"{"key": "value"}"#).is_none());
    }
}