use std::net::IpAddr;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
pub use bytes::Bytes;
pub mod network;
pub use network::{ClientIpResolver, IpSource, ResolvedClientIp};
pub mod state;
pub use state::{
Acquired, BucketParams, Clock, InMemoryStateStore, ManualClock, RateLimitState, StateStore,
SystemClock,
};
#[cfg(feature = "testkit")]
pub mod testkit;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Block { rule_id: String, reason: String },
Monitor { rule_id: String },
Score { rule_id: String, points: u32 },
Scores(Vec<ScoreItem>),
Reject {
rule_id: String,
reason: String,
status: u16,
retry_after: Option<u64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScoreItem {
pub rule_id: String,
pub severity: Severity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Critical,
Error,
Warning,
Notice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
Connection,
RequestLine,
Headers,
Body,
Response,
}
pub trait WafModule: Send + Sync {
fn id(&self) -> &str;
fn phase(&self) -> Phase;
fn init(&mut self, cfg: &Config);
fn inspect(&self, ctx: &RequestContext) -> Decision;
fn structural(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Config {
pub proxy: ProxyConfig,
pub waf: WafConfig,
#[serde(default)]
pub limits: LimitsConfig,
#[serde(default)]
pub modules: ModulesConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub resilience: ResilienceConfig,
#[serde(default)]
pub tls: TlsConfig,
#[serde(default)]
pub metrics: MetricsConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
proxy: ProxyConfig::default(),
waf: WafConfig::default(),
limits: LimitsConfig::default(),
modules: ModulesConfig::default(),
rate_limit: RateLimitConfig::default(),
network: NetworkConfig::default(),
resilience: ResilienceConfig::default(),
tls: TlsConfig::default(),
metrics: MetricsConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MetricsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_metrics_listen")]
pub listen: std::net::SocketAddr,
}
fn default_metrics_listen() -> std::net::SocketAddr {
"127.0.0.1:9090".parse().expect("valid loopback addr")
}
impl Default for MetricsConfig {
fn default() -> Self {
Self { enabled: false, listen: default_metrics_listen() }
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct TlsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub cert_path: String,
#[serde(default)]
pub key_path: String,
#[serde(default = "default_tls_alpn")]
pub alpn: Vec<String>,
}
fn default_tls_alpn() -> Vec<String> {
vec!["h2".to_string(), "http/1.1".to_string()]
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
enabled: false,
cert_path: String::new(),
key_path: String::new(),
alpn: default_tls_alpn(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailMode {
FailOpen,
FailClosed,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct ResilienceConfig {
#[serde(default = "default_on_upstream_error")]
pub on_upstream_error: FailMode,
#[serde(default = "default_on_internal_error")]
pub on_internal_error: FailMode,
#[serde(default = "default_on_config_error")]
pub on_config_error: FailMode,
#[serde(default = "default_on_parser_limit")]
pub on_parser_limit: FailMode,
#[serde(default = "default_upstream_timeout_ms")]
pub upstream_timeout_ms: u64,
}
fn default_on_upstream_error() -> FailMode { FailMode::FailClosed }
fn default_on_internal_error() -> FailMode { FailMode::FailOpen }
fn default_on_config_error() -> FailMode { FailMode::FailOpen }
fn default_on_parser_limit() -> FailMode { FailMode::FailClosed }
fn default_upstream_timeout_ms() -> u64 { 30_000 }
impl Default for ResilienceConfig {
fn default() -> Self {
Self {
on_upstream_error: default_on_upstream_error(),
on_internal_error: default_on_internal_error(),
on_config_error: default_on_config_error(),
on_parser_limit: default_on_parser_limit(),
upstream_timeout_ms: default_upstream_timeout_ms(),
}
}
}
impl ResilienceConfig {
pub fn upstream_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.upstream_timeout_ms)
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct NetworkConfig {
#[serde(default)]
pub trusted_proxies: Vec<String>,
#[serde(default = "default_client_ip_header")]
pub client_ip_header: String,
#[serde(default = "default_trusted_hops")]
pub trusted_hops: usize,
}
fn default_client_ip_header() -> String { "x-forwarded-for".to_string() }
fn default_trusted_hops() -> usize { 1 }
impl Default for NetworkConfig {
fn default() -> Self {
Self {
trusted_proxies: Vec::new(),
client_ip_header: default_client_ip_header(),
trusted_hops: default_trusted_hops(),
}
}
}
pub const MAX_PARANOIA_LEVEL: u8 = 4;
pub const MAX_TRUSTED_HOPS: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
InvalidBackend(String),
BlockThresholdZero,
ParanoiaOutOfRange(u8),
SeverityWeightZero(&'static str),
LimitZero(&'static str),
RateLimitValueZero(&'static str),
TrustedHopsOutOfRange(usize),
InvalidCidr(String),
EmptyClientIpHeader,
ResilienceTimeoutZero,
GraphqlCapZero(&'static str),
GrpcCapZero(&'static str),
TlsPathEmpty(&'static str),
TlsAlpnInvalid,
CrsNoFiles,
WasmNoPlugins,
WasmPluginPathEmpty,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBackend(b) => write!(
f,
"proxy.backend must be an absolute http(s) URL with a host, got {b:?}"
),
Self::BlockThresholdZero =>
write!(f, "waf.block_threshold must be >= 1 (0 would block everything)"),
Self::ParanoiaOutOfRange(p) =>
write!(f, "waf.paranoia_level must be in 1..={MAX_PARANOIA_LEVEL}, got {p}"),
Self::SeverityWeightZero(name) =>
write!(f, "waf.severity_scores.{name} must be >= 1 (a 0 weight makes the rule contribute nothing)"),
Self::LimitZero(name) =>
write!(f, "limits.{name} must be >= 1"),
Self::RateLimitValueZero(name) =>
write!(f, "rate_limit.{name} must be >= 1 when rate limiting is enabled"),
Self::TrustedHopsOutOfRange(h) =>
write!(f, "network.trusted_hops must be in 1..={MAX_TRUSTED_HOPS}, got {h}"),
Self::InvalidCidr(c) =>
write!(f, "network.trusted_proxies contains an invalid CIDR: {c:?}"),
Self::EmptyClientIpHeader =>
write!(f, "network.client_ip_header must not be empty"),
Self::ResilienceTimeoutZero =>
write!(f, "resilience.upstream_timeout_ms must be >= 1"),
Self::GraphqlCapZero(name) =>
write!(f, "modules.graphql.{name} must be >= 1 when the graphql module is enabled"),
Self::GrpcCapZero(name) =>
write!(f, "modules.grpc.{name} must be >= 1 when the grpc module is enabled"),
Self::TlsPathEmpty(name) =>
write!(f, "tls.{name} must be set (a PEM file path) when tls is enabled"),
Self::TlsAlpnInvalid =>
write!(f, "tls.alpn must be a non-empty list of non-empty protocol ids (e.g. \"h2\", \"http/1.1\")"),
Self::CrsNoFiles =>
write!(f, "modules.crs.files must list at least one file when the crs module is enabled"),
Self::WasmNoPlugins =>
write!(f, "modules.wasm.plugins must list at least one plugin when the wasm module is enabled"),
Self::WasmPluginPathEmpty =>
write!(f, "modules.wasm.plugins[].path must be a non-empty .wasm file path"),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn validate(&self) -> Result<(), ConfigError> {
let backend = self.proxy.backend.trim();
let authority = backend
.strip_prefix("http://")
.or_else(|| backend.strip_prefix("https://"));
match authority {
Some(a) if !a.is_empty() && !a.starts_with('/') => {}
_ => return Err(ConfigError::InvalidBackend(self.proxy.backend.clone())),
}
if self.waf.block_threshold == 0 {
return Err(ConfigError::BlockThresholdZero);
}
if !(1..=MAX_PARANOIA_LEVEL).contains(&self.waf.paranoia_level) {
return Err(ConfigError::ParanoiaOutOfRange(self.waf.paranoia_level));
}
let s = &self.waf.severity_scores;
for (name, v) in [
("critical", s.critical),
("error", s.error),
("warning", s.warning),
("notice", s.notice),
] {
if v == 0 {
return Err(ConfigError::SeverityWeightZero(name));
}
}
let l = &self.limits;
for (name, v) in [
("max_body_size", l.max_body_size),
("max_header_size", l.max_header_size),
("max_headers", l.max_headers),
("max_params", l.max_params),
("max_cookies", l.max_cookies),
("max_json_depth", l.max_json_depth),
] {
if v == 0 {
return Err(ConfigError::LimitZero(name));
}
}
if self.rate_limit.enabled {
let r = &self.rate_limit;
if r.requests == 0 {
return Err(ConfigError::RateLimitValueZero("requests"));
}
if r.window_seconds == 0 {
return Err(ConfigError::RateLimitValueZero("window_seconds"));
}
if matches!(r.burst, Some(0)) {
return Err(ConfigError::RateLimitValueZero("burst"));
}
if r.max_tracked_keys == 0 {
return Err(ConfigError::RateLimitValueZero("max_tracked_keys"));
}
if r.action == RateLimitAction::Score && r.score == 0 {
return Err(ConfigError::RateLimitValueZero("score"));
}
}
if !(1..=MAX_TRUSTED_HOPS).contains(&self.network.trusted_hops) {
return Err(ConfigError::TrustedHopsOutOfRange(self.network.trusted_hops));
}
for cidr in &self.network.trusted_proxies {
if !network::is_valid_cidr(cidr) {
return Err(ConfigError::InvalidCidr(cidr.clone()));
}
}
if self.network.client_ip_header.trim().is_empty() {
return Err(ConfigError::EmptyClientIpHeader);
}
if self.resilience.upstream_timeout_ms == 0 {
return Err(ConfigError::ResilienceTimeoutZero);
}
if self.tls.enabled {
if self.tls.cert_path.trim().is_empty() {
return Err(ConfigError::TlsPathEmpty("cert_path"));
}
if self.tls.key_path.trim().is_empty() {
return Err(ConfigError::TlsPathEmpty("key_path"));
}
if self.tls.alpn.is_empty() || self.tls.alpn.iter().any(|p| p.trim().is_empty()) {
return Err(ConfigError::TlsAlpnInvalid);
}
}
if self.modules.graphql.enabled {
let g = &self.modules.graphql;
for (name, v) in [
("max_depth", g.max_depth),
("max_aliases", g.max_aliases),
("max_fields", g.max_fields),
("max_directives", g.max_directives),
("max_batch", g.max_batch),
] {
if v == 0 {
return Err(ConfigError::GraphqlCapZero(name));
}
}
}
if self.modules.grpc.enabled {
let g = &self.modules.grpc;
for (name, zero) in [
("max_message_bytes", g.max_message_bytes == 0),
("max_fields", g.max_fields == 0),
("max_depth", g.max_depth == 0),
] {
if zero {
return Err(ConfigError::GrpcCapZero(name));
}
}
}
if self.modules.crs.enabled && self.modules.crs.files.is_empty() {
return Err(ConfigError::CrsNoFiles);
}
if self.modules.wasm.enabled {
if self.modules.wasm.plugins.is_empty() {
return Err(ConfigError::WasmNoPlugins);
}
if self.modules.wasm.plugins.iter().any(|p| p.path.trim().is_empty()) {
return Err(ConfigError::WasmPluginPathEmpty);
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RateLimitKey {
#[default]
ClientIp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RateLimitAction {
#[default]
Block,
Score,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub key: RateLimitKey,
#[serde(default = "default_rl_requests")]
pub requests: u32,
#[serde(default = "default_rl_window")]
pub window_seconds: u64,
#[serde(default)]
pub burst: Option<u32>,
#[serde(default)]
pub action: RateLimitAction,
#[serde(default = "default_rl_score")]
pub score: u32,
#[serde(default = "default_rl_max_keys")]
pub max_tracked_keys: usize,
}
fn default_rl_requests() -> u32 { 100 }
fn default_rl_window() -> u64 { 60 }
fn default_rl_score() -> u32 { 5 }
fn default_rl_max_keys() -> usize { 100_000 }
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: false,
key: RateLimitKey::ClientIp,
requests: default_rl_requests(),
window_seconds: default_rl_window(),
burst: None,
action: RateLimitAction::Block,
score: default_rl_score(),
max_tracked_keys: default_rl_max_keys(),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ModulesConfig {
#[serde(default)]
pub sqli: ModuleConfig,
#[serde(default)]
pub xss: ModuleConfig,
#[serde(default)]
pub path_traversal: ModuleConfig,
#[serde(default)]
pub rce: ModuleConfig,
#[serde(default)]
pub lfi_rfi: ModuleConfig,
#[serde(default)]
pub ssrf: ModuleConfig,
#[serde(default)]
pub ldap: ModuleConfig,
#[serde(default)]
pub nosql: ModuleConfig,
#[serde(default)]
pub mail: ModuleConfig,
#[serde(default)]
pub ssti: ModuleConfig,
#[serde(default)]
pub scanner: ModuleConfig,
#[serde(default)]
pub ssi: ModuleConfig,
#[serde(default)]
pub xxe: ModuleConfig,
#[serde(default)]
pub header_injection: ModuleConfig,
#[serde(default)]
pub request_smuggling: ModuleConfig,
#[serde(default)]
pub graphql: GraphqlConfig,
#[serde(default)]
pub grpc: GrpcConfig,
#[serde(default)]
pub crs: CrsConfig,
#[serde(default)]
pub wasm: WasmConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CrsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub files: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WasmConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_wasm_pool_size")]
pub pool_size: usize,
#[serde(default = "default_wasm_fuel")]
pub fuel_per_request: u64,
#[serde(default = "default_wasm_max_memory")]
pub max_memory_bytes: usize,
#[serde(default = "default_wasm_checkout_timeout_ms")]
pub checkout_timeout_ms: u64,
#[serde(default)]
pub plugins: Vec<WasmPluginConfig>,
}
fn default_wasm_pool_size() -> usize { 4 }
fn default_wasm_fuel() -> u64 { 10_000_000 }
fn default_wasm_max_memory() -> usize { 16 * 1024 * 1024 }
fn default_wasm_checkout_timeout_ms() -> u64 { 100 }
impl Default for WasmConfig {
fn default() -> Self {
WasmConfig {
enabled: false,
pool_size: default_wasm_pool_size(),
fuel_per_request: default_wasm_fuel(),
max_memory_bytes: default_wasm_max_memory(),
checkout_timeout_ms: default_wasm_checkout_timeout_ms(),
plugins: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct WasmPluginConfig {
pub path: String,
#[serde(default)]
pub config: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModuleConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool { true }
impl Default for ModuleConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct GraphqlConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_graphql_paths")]
pub paths: Vec<String>,
#[serde(default = "default_graphql_max_depth")]
pub max_depth: u32,
#[serde(default = "default_graphql_max_aliases")]
pub max_aliases: u32,
#[serde(default = "default_graphql_max_fields")]
pub max_fields: u32,
#[serde(default = "default_graphql_max_directives")]
pub max_directives: u32,
#[serde(default = "default_graphql_max_batch")]
pub max_batch: u32,
#[serde(default)]
pub block_introspection: bool,
}
fn default_graphql_paths() -> Vec<String> { vec!["/graphql".to_string()] }
fn default_graphql_max_depth() -> u32 { 15 }
fn default_graphql_max_aliases() -> u32 { 30 }
fn default_graphql_max_fields() -> u32 { 1000 }
fn default_graphql_max_directives() -> u32 { 50 }
fn default_graphql_max_batch() -> u32 { 10 }
impl Default for GraphqlConfig {
fn default() -> Self {
Self {
enabled: false,
paths: default_graphql_paths(),
max_depth: default_graphql_max_depth(),
max_aliases: default_graphql_max_aliases(),
max_fields: default_graphql_max_fields(),
max_directives: default_graphql_max_directives(),
max_batch: default_graphql_max_batch(),
block_introspection: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompressedPolicy {
Reject,
Passthrough,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GrpcConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_grpc_max_message_bytes")]
pub max_message_bytes: u64,
#[serde(default = "default_grpc_max_fields")]
pub max_fields: u32,
#[serde(default = "default_grpc_max_depth")]
pub max_depth: u32,
#[serde(default = "default_grpc_on_compressed")]
pub on_compressed: CompressedPolicy,
}
fn default_grpc_max_message_bytes() -> u64 { 4 * 1024 * 1024 }
fn default_grpc_max_fields() -> u32 { 4096 }
fn default_grpc_max_depth() -> u32 { 16 }
fn default_grpc_on_compressed() -> CompressedPolicy { CompressedPolicy::Reject }
impl Default for GrpcConfig {
fn default() -> Self {
Self {
enabled: false,
max_message_bytes: default_grpc_max_message_bytes(),
max_fields: default_grpc_max_fields(),
max_depth: default_grpc_max_depth(),
on_compressed: default_grpc_on_compressed(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct SeverityScores {
#[serde(default = "default_critical_score")]
pub critical: u32,
#[serde(default = "default_error_score")]
pub error: u32,
#[serde(default = "default_warning_score")]
pub warning: u32,
#[serde(default = "default_notice_score")]
pub notice: u32,
}
fn default_critical_score() -> u32 { 6 }
fn default_error_score() -> u32 { 4 }
fn default_warning_score() -> u32 { 3 }
fn default_notice_score() -> u32 { 2 }
impl Default for SeverityScores {
fn default() -> Self {
Self {
critical: default_critical_score(),
error: default_error_score(),
warning: default_warning_score(),
notice: default_notice_score(),
}
}
}
impl SeverityScores {
pub fn points_for(&self, severity: Severity) -> u32 {
match severity {
Severity::Critical => self.critical,
Severity::Error => self.error,
Severity::Warning => self.warning,
Severity::Notice => self.notice,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ProxyConfig {
pub listen: std::net::SocketAddr,
pub backend: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WafConfig {
pub mode: WafMode,
#[serde(default = "default_block_threshold")]
pub block_threshold: u32,
#[serde(default = "default_paranoia_level")]
pub paranoia_level: u8,
#[serde(default)]
pub severity_scores: SeverityScores,
}
fn default_block_threshold() -> u32 {
5
}
fn default_paranoia_level() -> u8 {
1
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
listen: "127.0.0.1:8080".parse().expect("valid loopback addr"),
backend: "http://localhost:8080".to_string(),
}
}
}
impl Default for WafConfig {
fn default() -> Self {
Self {
mode: WafMode::DetectionOnly,
block_threshold: default_block_threshold(),
paranoia_level: default_paranoia_level(),
severity_scores: SeverityScores::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WafMode {
DetectionOnly,
Blocking,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LimitsConfig {
#[serde(default = "default_max_body_size")]
pub max_body_size: usize,
#[serde(default = "default_max_header_size")]
pub max_header_size: usize,
#[serde(default = "default_max_headers")]
pub max_headers: usize,
#[serde(default = "default_max_params")]
pub max_params: usize,
#[serde(default = "default_max_cookies")]
pub max_cookies: usize,
#[serde(default = "default_max_json_depth")]
pub max_json_depth: usize,
}
fn default_max_body_size() -> usize { 1_048_576 } fn default_max_header_size() -> usize { 8_192 } fn default_max_headers() -> usize { 100 }
fn default_max_params() -> usize { 100 }
fn default_max_cookies() -> usize { 50 }
fn default_max_json_depth() -> usize { 20 }
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_body_size: default_max_body_size(),
max_header_size: default_max_header_size(),
max_headers: default_max_headers(),
max_params: default_max_params(),
max_cookies: default_max_cookies(),
max_json_depth: default_max_json_depth(),
}
}
}
#[derive(Debug, Clone, Default)]
pub enum ParsedBody {
#[default]
None,
FormUrlEncoded(Vec<(String, String)>),
Multipart(Vec<MultipartField>),
JsonFlattened(Vec<(String, String)>),
Raw(Bytes),
}
#[derive(Debug, Clone)]
pub struct MultipartField {
pub name: String,
pub filename: Option<String>,
pub content_type: Option<String>,
pub data: Bytes,
}
#[derive(Debug, Clone, Default)]
pub struct Normalized {
pub path: String,
pub query: Option<String>,
pub query_params: Vec<(String, String)>,
pub cookies: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: ParsedBody,
pub double_encoding_detected: bool,
pub derived_decoded: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreContribution {
pub module: String,
pub rule_id: String,
pub severity: Option<Severity>,
pub points: u32,
}
#[cfg(test)]
mod scoring_serde_tests {
use super::*;
#[test]
fn score_contribution_json_shape_is_stable() {
let items = vec![
ScoreContribution {
module: "sqli".to_string(),
rule_id: "sqli-union-select".to_string(),
severity: Some(Severity::Critical),
points: 5,
},
ScoreContribution {
module: "rate_limit".to_string(),
rule_id: "rl-client-ip".to_string(),
severity: None,
points: 3,
},
];
let json = serde_json::to_string(&items).unwrap();
assert_eq!(
json,
r#"[{"module":"sqli","rule_id":"sqli-union-select","severity":"critical","points":5},{"module":"rate_limit","rule_id":"rl-client-ip","severity":null,"points":3}]"#
);
let back: Vec<ScoreContribution> = serde_json::from_str(&json).unwrap();
assert_eq!(back, items);
}
}
#[derive(Debug)]
pub struct RequestContext {
pub client_ip: IpAddr,
pub request_id: String,
pub timestamp: SystemTime,
pub method: String,
pub path: String,
pub raw_path: String,
pub query: Option<String>,
pub http_version: String,
pub headers: Vec<(String, String)>,
pub cookies: Vec<(String, String)>,
pub body: Bytes,
pub normalized: Normalized,
pub score: u32,
pub score_contributions: Vec<ScoreContribution>,
}
#[cfg(test)]
mod config_validation_tests {
use super::*;
fn valid() -> Config {
Config {
proxy: ProxyConfig {
listen: "127.0.0.1:8080".parse().unwrap(),
backend: "http://localhost:3000".to_string(),
},
waf: WafConfig {
mode: WafMode::Blocking,
block_threshold: 5,
paranoia_level: 2,
severity_scores: SeverityScores::default(),
},
limits: LimitsConfig::default(),
modules: ModulesConfig::default(),
rate_limit: RateLimitConfig::default(),
network: NetworkConfig::default(),
resilience: ResilienceConfig::default(),
tls: TlsConfig::default(),
metrics: MetricsConfig::default(),
}
}
#[test]
fn valid_config_passes() {
assert!(valid().validate().is_ok());
}
#[test]
fn paranoia_4_is_legal_forward_compatible() {
let mut c = valid();
c.waf.paranoia_level = MAX_PARANOIA_LEVEL; assert!(c.validate().is_ok());
}
#[test]
fn paranoia_out_of_range_rejected() {
let mut c = valid();
c.waf.paranoia_level = 0;
assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(0)));
c.waf.paranoia_level = 5;
assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(5)));
}
#[test]
fn block_threshold_zero_rejected() {
let mut c = valid();
c.waf.block_threshold = 0;
assert_eq!(c.validate(), Err(ConfigError::BlockThresholdZero));
}
#[test]
fn grpc_disabled_ignores_caps() {
assert!(valid().validate().is_ok());
}
#[test]
fn grpc_enabled_rejects_zero_caps() {
let mut c = valid();
c.modules.grpc.enabled = true;
assert!(c.validate().is_ok()); c.modules.grpc.max_depth = 0;
assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_depth")));
c.modules.grpc.max_depth = 16;
c.modules.grpc.max_message_bytes = 0;
assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_message_bytes")));
}
#[test]
fn tls_disabled_ignores_empty_paths() {
assert!(valid().validate().is_ok());
}
#[test]
fn tls_enabled_requires_cert_and_key_paths() {
let mut c = valid();
c.tls.enabled = true;
assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("cert_path")));
c.tls.cert_path = "cert.pem".to_string();
assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("key_path")));
c.tls.key_path = "key.pem".to_string();
assert!(c.validate().is_ok());
}
#[test]
fn tls_enabled_rejects_empty_alpn() {
let mut c = valid();
c.tls.enabled = true;
c.tls.cert_path = "cert.pem".to_string();
c.tls.key_path = "key.pem".to_string();
c.tls.alpn = vec![];
assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
c.tls.alpn = vec!["h2".to_string(), " ".to_string()];
assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
}
#[test]
fn crs_enabled_requires_files() {
let mut c = valid();
c.modules.crs.enabled = true;
assert_eq!(c.validate(), Err(ConfigError::CrsNoFiles));
c.modules.crs.files = vec!["rules.conf".to_string()];
assert!(c.validate().is_ok());
}
#[test]
fn crs_disabled_ignores_empty_files() {
assert!(valid().validate().is_ok());
}
#[test]
fn wasm_enabled_requires_plugins_with_paths() {
let mut c = valid();
c.modules.wasm.enabled = true;
assert_eq!(c.validate(), Err(ConfigError::WasmNoPlugins));
c.modules.wasm.plugins = vec![WasmPluginConfig { path: " ".into(), config: None }];
assert_eq!(c.validate(), Err(ConfigError::WasmPluginPathEmpty));
c.modules.wasm.plugins = vec![WasmPluginConfig { path: "f.wasm".into(), config: None }];
assert!(c.validate().is_ok());
}
#[test]
fn wasm_disabled_ignores_empty_plugins() {
assert!(valid().validate().is_ok());
}
#[test]
fn zero_severity_weight_rejected() {
let mut c = valid();
c.waf.severity_scores.warning = 0;
assert_eq!(c.validate(), Err(ConfigError::SeverityWeightZero("warning")));
}
#[test]
fn zero_limit_rejected() {
let mut c = valid();
c.limits.max_json_depth = 0;
assert_eq!(c.validate(), Err(ConfigError::LimitZero("max_json_depth")));
}
#[test]
fn invalid_backend_rejected() {
let mut c = valid();
c.proxy.backend = "localhost:3000".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
c.proxy.backend = "http://".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
}
#[test]
fn rate_limit_values_checked_only_when_enabled() {
let mut c = valid();
c.rate_limit.enabled = false;
c.rate_limit.requests = 0;
assert!(c.validate().is_ok());
c.rate_limit.enabled = true;
assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("requests")));
}
#[test]
fn rate_limit_score_action_requires_positive_score() {
let mut c = valid();
c.rate_limit.enabled = true;
c.rate_limit.action = RateLimitAction::Score;
c.rate_limit.score = 0;
assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("score")));
}
#[test]
fn trusted_hops_out_of_range_rejected() {
let mut c = valid();
c.network.trusted_hops = 0;
assert_eq!(c.validate(), Err(ConfigError::TrustedHopsOutOfRange(0)));
c.network.trusted_hops = MAX_TRUSTED_HOPS + 1;
assert_eq!(
c.validate(),
Err(ConfigError::TrustedHopsOutOfRange(MAX_TRUSTED_HOPS + 1))
);
}
#[test]
fn invalid_cidr_in_trusted_proxies_rejected() {
let mut c = valid();
c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "999.0.0.0/8".to_string()];
assert_eq!(
c.validate(),
Err(ConfigError::InvalidCidr("999.0.0.0/8".to_string()))
);
}
#[test]
fn valid_cidrs_pass() {
let mut c = valid();
c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "::1".to_string()];
assert!(c.validate().is_ok());
}
#[test]
fn empty_client_ip_header_rejected() {
let mut c = valid();
c.network.client_ip_header = " ".to_string();
assert_eq!(c.validate(), Err(ConfigError::EmptyClientIpHeader));
}
#[test]
fn zero_upstream_timeout_rejected() {
let mut c = valid();
c.resilience.upstream_timeout_ms = 0;
assert_eq!(c.validate(), Err(ConfigError::ResilienceTimeoutZero));
}
#[test]
fn resilience_defaults_match_documented_posture() {
let r = ResilienceConfig::default();
assert_eq!(r.on_internal_error, FailMode::FailOpen);
assert_eq!(r.on_upstream_error, FailMode::FailClosed);
assert_eq!(r.on_config_error, FailMode::FailOpen);
assert_eq!(r.on_parser_limit, FailMode::FailClosed);
}
}