use http::{Request, StatusCode};
use security_rust::Scanner;
pub use security_rust::{AttackCategory, DetectionResult, ScannerBuilder, Severity};
mod layer;
pub use layer::{SecurityBodyLayer, SecurityBodyService, SecurityLayer, SecurityService};
#[derive(Debug, thiserror::Error)]
pub enum SecurityError {
#[error("attack blocked: {0}")]
AttackBlocked(String),
#[error("request body too large")]
BodyTooLarge,
#[error("inner error: {0}")]
Inner(#[from] Box<dyn std::error::Error + Send + Sync>),
}
impl SecurityError {
pub fn to_http_status(&self) -> StatusCode {
match self {
Self::AttackBlocked(_) => StatusCode::FORBIDDEN,
Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
Self::Inner(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl axum::response::IntoResponse for SecurityError {
fn into_response(self) -> axum::response::Response {
let status = self.to_http_status();
let body = match &self {
Self::AttackBlocked(types) => {
format!(r#"{{"error":"attack blocked","types":"{types}"}}"#)
}
Self::BodyTooLarge => r#"{"error":"request body too large"}"#.to_string(),
Self::Inner(e) => {
tracing::error!(error = %e, "security middleware internal error");
r#"{"error":"internal server error"}"#.to_string()
}
};
(status, body).into_response()
}
}
pub struct SecurityScanner {
scanner: Scanner,
}
impl SecurityScanner {
pub fn new() -> Self {
Self {
scanner: Scanner::default(),
}
}
pub fn scan(&self, input: &str) -> Vec<DetectionResult> {
self.scanner.scan(input)
}
pub fn scan_parts(&self, parts: &[&str]) -> Vec<DetectionResult> {
let mut results = Vec::with_capacity(parts.len() * 2);
for part in parts {
results.extend(self.scanner.scan(part));
}
results
}
pub fn scan_body(&self, body: &[u8]) -> Vec<DetectionResult> {
if let Ok(s) = std::str::from_utf8(body) {
self.scanner.scan(s)
} else {
Vec::new()
}
}
}
impl Default for SecurityScanner {
fn default() -> Self {
Self::new()
}
}
fn evaluate(results: &[DetectionResult]) -> Option<SecurityError> {
let mut blocked = false;
for r in results {
tracing::warn!(
attack_type = %r.attack_type,
category = ?r.category,
severity = ?r.severity,
matched = %r.matched_pattern,
"attack detected"
);
if r.attack_type != "jwt_attack"
&& matches!(r.severity, Severity::High | Severity::Critical)
{
blocked = true;
}
}
if blocked {
let attack_types: Vec<String> = results.iter().map(|r| r.attack_type.to_string()).collect();
return Some(SecurityError::AttackBlocked(attack_types.join(", ")));
}
None
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
out.push(h * 16 + l);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn is_proxy_header(name: &http::header::HeaderName) -> bool {
matches!(
name.as_str(),
"x-forwarded-for"
| "x-real-ip"
| "forwarded"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-forwarded-port"
| "authorization"
)
}
fn request_parts<B>(req: &Request<B>) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
parts.push(percent_decode(&req.uri().to_string()));
for (name, value) in req.headers() {
if is_proxy_header(name) {
continue;
}
if let Ok(v) = value.to_str() {
parts.push(v.to_string());
}
}
parts
}
#[cfg(test)]
mod tests;