use axum::{
body::Body,
extract::Request,
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware::Next,
response::Response,
};
mod detect;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;
const MAX_INSPECTED_REQUEST_BYTES: usize = 1024 * 1024;
static PATH_TRAVERSAL_PATTERNS: &[&str] = &["../", "..\\", "/etc/passwd", "win.ini"];
fn plain_response(status: StatusCode, message: &'static str) -> Response {
let mut response = Response::new(Body::from(message));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn forbidden_response() -> Response {
plain_response(
StatusCode::FORBIDDEN,
"Access Denied: Malicious pattern detected by Rullst Shield WAF.",
)
}
fn should_inspect_body(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(super::media_type::essence)
.is_some_and(super::media_type::is_inspected_request_body)
}
fn has_identity_encoding(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_ENCODING)
.is_none_or(|value| value.as_bytes().eq_ignore_ascii_case(b"identity"))
}
fn declared_body_is_too_large(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
.is_some_and(|length| length > MAX_INSPECTED_REQUEST_BYTES)
}
#[cfg_attr(mutants, mutants::skip)]
fn hex_decode_char(c1: u8, c2: u8) -> Option<u8> {
let b1 = (c1 as char).to_digit(16)?;
let b2 = (c2 as char).to_digit(16)?;
Some(((b1 << 4) | b2) as u8)
}
#[cfg_attr(mutants, mutants::skip)]
fn url_decode(s: &str) -> String {
let mut decoded_bytes = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'+' {
decoded_bytes.push(b' ');
i += 1;
continue;
}
if b == b'%' && i + 2 < bytes.len() {
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
if let Some(d) = hex_decode_char(h1, h2) {
decoded_bytes.push(d);
i += 3;
continue;
}
}
decoded_bytes.push(b);
i += 1;
}
String::from_utf8_lossy(&decoded_bytes).into_owned()
}
fn contains_malicious_pattern(payload: &str) -> bool {
let payload_decoded = url_decode(payload);
let payload_lower = payload_decoded.to_lowercase();
PATH_TRAVERSAL_PATTERNS
.iter()
.any(|pattern| payload_lower.contains(pattern))
|| detect::is_injection(&payload_lower)
}
fn json_contains_malicious_pattern(payload: &str) -> bool {
let Ok(document) = serde_json::from_str::<serde_json::Value>(payload) else {
return contains_malicious_pattern(payload);
};
let mut pending = vec![&document];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::String(text) if contains_malicious_pattern(text) => return true,
serde_json::Value::Array(items) => pending.extend(items),
serde_json::Value::Object(fields) => {
for (key, field) in fields {
if contains_malicious_pattern(key) {
return true;
}
pending.push(field);
}
}
_ => {}
}
}
false
}
fn body_is_json(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(super::media_type::essence)
.is_some_and(super::media_type::is_json)
}
async fn inspect_and_restore_body(req: Request) -> Result<Request, Box<Response>> {
if !should_inspect_body(req.headers()) {
return Ok(req);
}
if !has_identity_encoding(req.headers()) {
return Err(Box::new(plain_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Encoded request bodies cannot be inspected by the WAF.",
)));
}
if declared_body_is_too_large(req.headers()) {
return Err(Box::new(plain_response(
StatusCode::PAYLOAD_TOO_LARGE,
"Request body exceeds the WAF inspection limit.",
)));
}
let (parts, body) = req.into_parts();
let bytes = axum::body::to_bytes(body, MAX_INSPECTED_REQUEST_BYTES)
.await
.map_err(|_| {
Box::new(plain_response(
StatusCode::PAYLOAD_TOO_LARGE,
"Request body could not be inspected within the WAF limit.",
))
})?;
let payload = std::str::from_utf8(&bytes).map_err(|_| {
Box::new(plain_response(
StatusCode::BAD_REQUEST,
"Declared textual request body is not valid UTF-8.",
))
})?;
let malicious = if body_is_json(&parts.headers) {
json_contains_malicious_pattern(payload)
} else {
contains_malicious_pattern(payload)
};
if malicious {
return Err(Box::new(forbidden_response()));
}
Ok(Request::from_parts(parts, Body::from(bytes)))
}
pub async fn waf_middleware(mut req: Request, next: Next) -> Response {
if let Some(ua) = req
.headers()
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
{
let ua_lower = ua.to_lowercase();
let suspicious_agents = req
.extensions()
.get::<crate::config::SecurityConfig>()
.map(|cfg| cfg.user_agent_blocklist.clone())
.unwrap_or_else(|| {
crate::config::RullstConfig::global()
.security
.user_agent_blocklist
.clone()
});
for agent in suspicious_agents {
if ua_lower.contains(&agent.to_lowercase()) {
return plain_response(
StatusCode::FORBIDDEN,
"Access Denied: Suspicious User-Agent blocked by Rullst Shield WAF.",
);
}
}
}
if let Some(query) = req.uri().query() {
if contains_malicious_pattern(query) {
return forbidden_response();
}
}
if let Some(referer) = req
.headers()
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
&& contains_malicious_pattern(referer)
{
return forbidden_response();
}
if let Some(cookies) = req
.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
&& cookies
.split(';')
.any(|pair| contains_malicious_pattern(pair.trim()))
{
return forbidden_response();
}
req = match inspect_and_restore_body(req).await {
Ok(req) => req,
Err(response) => return *response,
};
next.run(req).await
}