bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! CRLF injection detection — blocks header injection via `%0d%0a` in
//! paths and query strings. Classic nginx/Apache attack vector for
//! Set-Cookie injection, redirect hijacking, and response splitting.

use std::sync::OnceLock;

use regex::RegexSet;

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

static CRLF_DESCRIPTIONS: &[&str] = &[
    "CRLF injection: %0d%0a in input",
    "CRLF injection: %0D%0A (uppercase)",
    "CRLF injection: literal CR/LF bytes",
    "CRLF injection: double-encoded %250d%250a",
    "CRLF injection: unicode line separator",
];

fn patterns() -> &'static RegexSet {
    CRLF_PATTERNS.get_or_init(|| {
        RegexSet::new([
            // 0: Percent-encoded CRLF (mixed case)
            r"(?i)%0[dD]%0[aA]",
            // 1: Percent-encoded lone CR or LF followed by header-like pattern
            r"(?i)(%0[aA]|%0[dD])(Set-Cookie|Location|Host|X-)",
            // 2: Literal CR or LF bytes (if they arrive un-decoded)
            r"[\r\n]",
            // 3: Double-encoded CRLF
            r"(?i)%250[dD]%250[aA]",
            // 4: Unicode line separators (U+2028, U+2029)
            r"(\xe2\x80\xa8|\xe2\x80\xa9)",
        ])
        .expect("CRLF regex patterns must compile")
    })
}

/// Check an input string for CRLF injection patterns.
/// Returns `Some(description)` if a pattern matches.
pub fn check_crlf(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(
            CRLF_DESCRIPTIONS
                .get(idx)
                .unwrap_or(&"CRLF injection")
                .to_string(),
        )
    }
}

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

    #[test]
    fn detects_percent_encoded_crlf() {
        assert!(check_crlf("/page%0d%0aSet-Cookie:evil=1").is_some());
        assert!(check_crlf("q=test%0D%0ALocation:http://evil.com").is_some());
    }

    #[test]
    fn detects_double_encoded_crlf() {
        assert!(check_crlf("/page%250d%250a").is_some());
    }

    #[test]
    fn detects_literal_newlines() {
        assert!(check_crlf("header\r\ninjection").is_some());
        assert!(check_crlf("line\nbreak").is_some());
    }

    #[test]
    fn allows_normal_paths() {
        assert!(check_crlf("/api/users/123").is_none());
        assert!(check_crlf("/search?q=hello").is_none());
    }

    #[test]
    fn allows_normal_queries() {
        assert!(check_crlf("q=hello+world&page=1").is_none());
    }
}