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([
r"(?i)%0[dD]%0[aA]",
r"(?i)(%0[aA]|%0[dD])(Set-Cookie|Location|Host|X-)",
r"[\r\n]",
r"(?i)%250[dD]%250[aA]",
r"(\xe2\x80\xa8|\xe2\x80\xa9)",
])
.expect("CRLF regex patterns must compile")
})
}
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());
}
}