bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! Path traversal detection — `../`, `..\`, URL-encoded variants, null-byte
//! injection, and `/etc/passwd`-style direct references.

use std::sync::OnceLock;

use regex::RegexSet;

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

static TRAVERSAL_DESCRIPTIONS: &[&str] = &[
    "Path traversal: ../",
    "Path traversal: ..\\",
    "URL-encoded traversal: %2e%2e",
    "Double-encoded traversal: %252e",
    "Null byte injection",
    "Sensitive file: /etc/passwd",
    "Sensitive file: /etc/shadow",
    "Proc filesystem: /proc/self",
    "Sensitive file: /etc/hosts",
    "Windows system files",
    "URL-encoded slash traversal",
];

fn patterns() -> &'static RegexSet {
    TRAVERSAL_PATTERNS.get_or_init(|| {
        RegexSet::new([
            // 0: ../
            r"\.\./",
            // 1: ..\
            r"\.\.\x5c",
            // 2: URL-encoded %2e%2e (case-insensitive)
            r"(?i)(%2e){2}[/%5c]",
            // 3: Double-encoded %252e%252e
            r"(?i)(%252e){2}",
            // 4: Null byte injection
            r"(?i)(%00|\\x00|\\0)",
            // 5: /etc/passwd
            r"(?i)/etc/passwd",
            // 6: /etc/shadow
            r"(?i)/etc/shadow",
            // 7: /proc/self
            r"(?i)/proc/self",
            // 8: /etc/hosts
            r"(?i)/etc/hosts",
            // 9: Windows system files
            r"(?i)(boot\.ini|win\.ini|system32|cmd\.exe)",
            // 10: URL-encoded slash traversal
            r"(?i)\.(%2f|%5c)\.",
        ])
        .expect("traversal regex patterns must compile")
    })
}

/// Check a path for traversal attack patterns.
/// Returns `Some(description)` if a pattern matches.
pub fn check_traversal(path: &str) -> Option<String> {
    let set = patterns();
    let matches: Vec<_> = set.matches(path).into_iter().collect();
    if matches.is_empty() {
        None
    } else {
        let idx = matches[0];
        Some(
            TRAVERSAL_DESCRIPTIONS
                .get(idx)
                .unwrap_or(&"path traversal")
                .to_string(),
        )
    }
}

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

    #[test]
    fn detects_dot_dot_slash() {
        assert!(check_traversal("../../etc/passwd").is_some());
        assert!(check_traversal("/var/www/../../../etc/shadow").is_some());
    }

    #[test]
    fn detects_dot_dot_backslash() {
        assert!(check_traversal("..\\..\\windows\\system32").is_some());
    }

    #[test]
    fn detects_url_encoded_traversal() {
        assert!(check_traversal("%2e%2e%2f%2e%2e%2f").is_some());
        assert!(check_traversal("%2e%2e/etc/passwd").is_some());
    }

    #[test]
    fn detects_double_encoded() {
        assert!(check_traversal("%252e%252e").is_some());
    }

    #[test]
    fn detects_null_byte() {
        assert!(check_traversal("/uploads/shell.php%00.jpg").is_some());
    }

    #[test]
    fn detects_etc_passwd() {
        assert!(check_traversal("/etc/passwd").is_some());
    }

    #[test]
    fn detects_etc_shadow() {
        assert!(check_traversal("/etc/shadow").is_some());
    }

    #[test]
    fn detects_proc_self() {
        assert!(check_traversal("/proc/self/environ").is_some());
    }

    #[test]
    fn detects_windows_files() {
        assert!(check_traversal("C:\\boot.ini").is_some());
        assert!(check_traversal("cmd.exe /c dir").is_some());
    }

    #[test]
    fn detects_mixed_encoding() {
        assert!(check_traversal(".%2f.%2f.%2f").is_some());
    }

    // ---- False positives ----

    #[test]
    fn allows_normal_paths() {
        assert!(check_traversal("/api/users/123").is_none());
        assert!(check_traversal("/static/css/main.css").is_none());
    }

    #[test]
    fn allows_dots_in_filenames() {
        assert!(check_traversal("/uploads/image.v2.png").is_none());
    }

    #[test]
    fn allows_normal_query_params() {
        assert!(check_traversal("/search?q=hello+world").is_none());
    }
}