use regex::Regex;
use tracing::warn;
use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
#[derive(Clone, Copy, PartialEq)]
enum Scope {
All,
NonBody,
HostHeaders,
Body,
}
struct HdrRule {
id: &'static str,
pattern: &'static str,
severity: Severity,
paranoia: u8,
scope: Scope,
}
static HEADER_INJECTION_RULES: &[HdrRule] = &[
HdrRule {
id: "hdr-crlf-header-injection",
pattern: r"(?i)[\r\n]\s*(?:set-cookie|location|content-type|content-length|content-disposition|refresh|link|x-forwarded-for|x-forwarded-host|host)\s*:",
severity: Severity::Critical,
paranoia: 1,
scope: Scope::All,
},
HdrRule {
id: "hdr-crlf-control-char",
pattern: r"[\r\n]",
severity: Severity::Warning,
paranoia: 2,
scope: Scope::NonBody,
},
HdrRule {
id: "hdr-host-injection",
pattern: r"(?i)(?:[/@]|https?:)",
severity: Severity::Warning,
paranoia: 2,
scope: Scope::HostHeaders,
},
HdrRule {
id: "hdr-crlf-in-body",
pattern: r"[\r\n]",
severity: Severity::Notice,
paranoia: 3,
scope: Scope::Body,
},
];
pub fn rule_meta() -> Vec<(&'static str, &'static str, u8, bool)> {
HEADER_INJECTION_RULES
.iter()
.map(|r| (r.id, r.pattern, r.paranoia, matches!(r.scope, Scope::HostHeaders)))
.collect()
}
#[derive(Default)]
pub struct HeaderInjectionModule {
rules: Vec<(&'static HdrRule, Regex)>,
}
impl HeaderInjectionModule {
pub fn new() -> Self {
Self::default()
}
}
fn host_header_values(ctx: &RequestContext) -> Vec<&str> {
ctx.normalized
.headers
.iter()
.filter(|(name, _)| name == "host" || name == "x-forwarded-host")
.map(|(_, v)| v.as_str())
.collect()
}
impl WafModule for HeaderInjectionModule {
fn id(&self) -> &str {
"header_injection"
}
fn phase(&self) -> Phase {
Phase::Headers
}
fn init(&mut self, cfg: &Config) {
let pl = cfg.waf.paranoia_level;
self.rules = HEADER_INJECTION_RULES
.iter()
.filter(|r| r.paranoia <= pl)
.map(|r| {
let re = Regex::new(r.pattern)
.expect("header-injection rule compilation failed — check patterns at startup");
(r, re)
})
.collect();
}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if self.rules.is_empty() {
return Decision::Allow;
}
let path: &str = ctx.normalized.path.as_str();
let query: Vec<&str> = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str()).collect();
let cookies: Vec<&str> = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str()).collect();
let headers: Vec<&str> = ctx.normalized.headers.iter().map(|(_, v)| v.as_str()).collect();
let body_owned = crate::body_str_values(&ctx.normalized.body);
let body: Vec<&str> = body_owned.iter().map(String::as_str).collect();
let hosts = host_header_values(ctx);
let mut items: Vec<ScoreItem> = Vec::new();
for (rule, re) in &self.rules {
let matched = match rule.scope {
Scope::All => {
re.is_match(path)
|| query.iter().chain(&cookies).chain(&headers).chain(&body).any(|v| re.is_match(v))
}
Scope::NonBody => {
re.is_match(path)
|| query.iter().chain(&cookies).chain(&headers).any(|v| re.is_match(v))
}
Scope::HostHeaders => hosts.iter().any(|v| re.is_match(v)),
Scope::Body => body.iter().any(|v| re.is_match(v)),
};
if matched {
warn!(
request_id = %ctx.request_id,
rule_id = %rule.id,
severity = ?rule.severity,
"header-injection detection"
);
items.push(ScoreItem {
rule_id: rule.id.to_string(),
severity: rule.severity,
});
}
}
if items.is_empty() {
Decision::Allow
} else {
Decision::Scores(items)
}
}
}