Skip to main content

ecat_security/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2use http::{Request, StatusCode};
3use security_rust::Scanner;
4
5pub use security_rust::{AttackCategory, DetectionResult, ScannerBuilder, Severity};
6
7mod layer;
8pub use layer::{SecurityBodyLayer, SecurityBodyService, SecurityLayer, SecurityService};
9
10#[derive(Debug, thiserror::Error)]
11pub enum SecurityError {
12    #[error("attack blocked: {0}")]
13    AttackBlocked(String),
14    /// 请求体超过 body_limit 上限(读体阶段即拒绝,不进入扫描)。
15    #[error("request body too large")]
16    BodyTooLarge,
17    #[error("inner error: {0}")]
18    Inner(#[from] Box<dyn std::error::Error + Send + Sync>),
19}
20
21impl SecurityError {
22    pub fn to_http_status(&self) -> StatusCode {
23        match self {
24            Self::AttackBlocked(_) => StatusCode::FORBIDDEN,
25            Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
26            Self::Inner(_) => StatusCode::INTERNAL_SERVER_ERROR,
27        }
28    }
29}
30
31/// 拦截结果映射为 HTTP 响应:攻击拦截为 403,请求体超限为 413,内部
32/// 错误为 500。内部错误的原始信息只进日志,响应体保持通用文案,避免
33/// 把内部错误细节(文件路径、SQL、堆栈)泄露给客户端。
34impl axum::response::IntoResponse for SecurityError {
35    fn into_response(self) -> axum::response::Response {
36        let status = self.to_http_status();
37        let body = match &self {
38            Self::AttackBlocked(types) => {
39                format!(r#"{{"error":"attack blocked","types":"{types}"}}"#)
40            }
41            Self::BodyTooLarge => r#"{"error":"request body too large"}"#.to_string(),
42            Self::Inner(e) => {
43                tracing::error!(error = %e, "security middleware internal error");
44                r#"{"error":"internal server error"}"#.to_string()
45            }
46        };
47        (status, body).into_response()
48    }
49}
50
51/// Wraps `security_rust::Scanner` with convenient constructors.
52pub struct SecurityScanner {
53    scanner: Scanner,
54}
55
56impl SecurityScanner {
57    /// Create scanner with default detector configuration.
58    pub fn new() -> Self {
59        Self {
60            scanner: Scanner::default(),
61        }
62    }
63
64    /// Scan a single string through all detectors.
65    pub fn scan(&self, input: &str) -> Vec<DetectionResult> {
66        self.scanner.scan(input)
67    }
68
69    /// Scan multiple request parts (path, headers, body, etc.).
70    pub fn scan_parts(&self, parts: &[&str]) -> Vec<DetectionResult> {
71        let mut results = Vec::with_capacity(parts.len() * 2);
72        for part in parts {
73            results.extend(self.scanner.scan(part));
74        }
75        results
76    }
77
78    /// Scan request body bytes. Converts to string for analysis.
79    pub fn scan_body(&self, body: &[u8]) -> Vec<DetectionResult> {
80        if let Ok(s) = std::str::from_utf8(body) {
81            self.scanner.scan(s)
82        } else {
83            Vec::new()
84        }
85    }
86}
87
88impl Default for SecurityScanner {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94/// Logs detections and returns a blocking error when a High/Critical attack
95/// was found. Shared by the header-scanning and body-scanning middlewares.
96fn evaluate(results: &[DetectionResult]) -> Option<SecurityError> {
97    let mut blocked = false;
98    for r in results {
99        tracing::warn!(
100            attack_type = %r.attack_type,
101            category = ?r.category,
102            severity = ?r.severity,
103            matched = %r.matched_pattern,
104            "attack detected"
105        );
106        // jwt_attack 的宽正则(ey..ey.. 匹配一切标准 JWT)会误伤合法 token:
107        // 服务端鉴权由 JwtAuthLayer 验签把关(alg:none/伪造签名在验签层拒绝),
108        // 此处仅记日志不拦截。
109        if r.attack_type != "jwt_attack"
110            && matches!(r.severity, Severity::High | Severity::Critical)
111        {
112            blocked = true;
113        }
114    }
115    if blocked {
116        let attack_types: Vec<String> = results.iter().map(|r| r.attack_type.to_string()).collect();
117        return Some(SecurityError::AttackBlocked(attack_types.join(", ")));
118    }
119    None
120}
121
122/// 百分号解码,仅用于扫描检测;原始 URI 在响应/日志/转发中保持不变。
123/// 无效的 % 序列原样保留,非 UTF-8 解码结果按 replacement 字符处理。
124fn percent_decode(s: &str) -> String {
125    let bytes = s.as_bytes();
126    let mut out = Vec::with_capacity(bytes.len());
127    let mut i = 0;
128    while i < bytes.len() {
129        if bytes[i] == b'%'
130            && i + 2 < bytes.len()
131            && let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
132        {
133            out.push(h * 16 + l);
134            i += 3;
135            continue;
136        }
137        out.push(bytes[i]);
138        i += 1;
139    }
140    String::from_utf8_lossy(&out).into_owned()
141}
142
143fn hex_val(b: u8) -> Option<u8> {
144    match b {
145        b'0'..=b'9' => Some(b - b'0'),
146        b'a'..=b'f' => Some(b - b'a' + 10),
147        b'A'..=b'F' => Some(b - b'A' + 10),
148        _ => None,
149    }
150}
151
152/// Builds the scan list from URI and headers (shared by both middlewares).
153/// URI 先做百分号解码再扫描:`?q=SELECT%20*%20FROM%20users` 若不解码会绕过
154/// 要求字面空白的 SQLi 正则。解码仅用于检测,URI 本身不变。
155/// 代理拓扑头(X-Forwarded-For/X-Real-IP/Forwarded 等)由网关重写,携带
156/// 内网 IP(如 docker 网关 172.x),SSRF 检测会误伤内网部署,跳过不扫。
157/// Authorization 同理:JWT 是本站正常鉴权流量,jwt_attack 规则会误报,
158/// 扫描跳过(token 的校验由 JwtAuthLayer 负责)。
159fn is_proxy_header(name: &http::header::HeaderName) -> bool {
160    matches!(
161        name.as_str(),
162        "x-forwarded-for"
163            | "x-real-ip"
164            | "forwarded"
165            | "x-forwarded-host"
166            | "x-forwarded-proto"
167            | "x-forwarded-port"
168            | "authorization"
169    )
170}
171
172fn request_parts<B>(req: &Request<B>) -> Vec<String> {
173    let mut parts: Vec<String> = Vec::new();
174    parts.push(percent_decode(&req.uri().to_string()));
175    for (name, value) in req.headers() {
176        if is_proxy_header(name) {
177            continue;
178        }
179        if let Ok(v) = value.to_str() {
180            parts.push(v.to_string());
181        }
182    }
183    parts
184}
185
186#[cfg(test)]
187mod tests;