use std::convert::Infallible;
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::{Bytes, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use waf_core::{Config, RateLimitAction, RateLimitConfig, RateLimitKey, WafMode};
use waf_proxy::Proxy;
type TestBody = BoxBody<Bytes, hyper::Error>;
fn empty_body() -> TestBody {
Empty::new().map_err(|never| match never {}).boxed()
}
fn test_client() -> Client<HttpConnector, TestBody> {
Client::builder(TokioExecutor::new()).build(HttpConnector::new())
}
async fn start_echo_backend() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(|req: Request<Incoming>| async move {
let pq = req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("/").to_string();
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(format!("ok:{pq}")))))
}),
)
.await
.ok();
});
}
});
addr
}
fn metrics_config(backend: std::net::SocketAddr) -> Config {
let mut c = Config::default();
c.proxy.listen = "127.0.0.1:0".parse().unwrap();
c.proxy.backend = format!("http://{backend}");
c.waf.mode = WafMode::Blocking;
c.metrics.enabled = true;
c.metrics.listen = "127.0.0.1:0".parse().unwrap();
c
}
fn metrics_config_rate_limited(backend: std::net::SocketAddr) -> Config {
let mut c = metrics_config(backend);
c.rate_limit = RateLimitConfig {
enabled: true,
key: RateLimitKey::ClientIp,
requests: 1,
window_seconds: 60,
burst: Some(1),
action: RateLimitAction::Block,
score: 5,
max_tracked_keys: 1000,
};
c
}
const SQLI: &str = "/?q=1%20UNION%20SELECT%20a%2Cb%20FROM%20users--";
async fn get_status(client: &Client<HttpConnector, TestBody>, url: String) -> u16 {
client
.request(Request::builder().method("GET").uri(url).body(empty_body()).unwrap())
.await
.unwrap()
.status()
.as_u16()
}
async fn get_text(client: &Client<HttpConnector, TestBody>, url: String) -> (u16, String) {
let resp = client
.request(Request::builder().method("GET").uri(url).body(empty_body()).unwrap())
.await
.unwrap();
let status = resp.status().as_u16();
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&body).into_owned())
}
#[tokio::test]
async fn metrics_record_decisions_separate_listener_no_leak() {
let backend = start_echo_backend().await;
let proxy = Proxy::bind(&metrics_config(backend)).await.unwrap();
let data_addr = proxy.local_addr().unwrap();
let metrics_addr = proxy.metrics_addr().expect("metrics enabled");
assert_ne!(data_addr, metrics_addr, "metrics must be on a SEPARATE listener");
tokio::spawn(proxy.run());
let client = test_client();
assert_eq!(get_status(&client, format!("http://{data_addr}/a")).await, 200);
assert_eq!(get_status(&client, format!("http://{data_addr}{SQLI}")).await, 403);
let (status, text) = get_text(&client, format!("http://{metrics_addr}/metrics")).await;
assert_eq!(status, 200);
assert!(text.contains("waf_requests_total{decision=\"allowed\"} 1"), "got:\n{text}");
assert!(text.contains("waf_requests_total{decision=\"blocked\"} 1"), "got:\n{text}");
assert!(text.contains("waf_requests_total{decision=\"rate_limited\"} 0"), "got:\n{text}");
assert!(text.contains("waf_request_duration_seconds_count 2"), "got:\n{text}");
assert!(text.contains("waf_request_duration_seconds_bucket{le=\"+Inf\"} 2"));
assert!(text.contains("waf_up 1"));
assert_eq!(get_text(&client, format!("http://{metrics_addr}/")).await.0, 404);
assert_eq!(get_text(&client, format!("http://{metrics_addr}/metricz")).await.0, 404);
let (data_status, data_body) = get_text(&client, format!("http://{data_addr}/metrics")).await;
assert_eq!(data_status, 200);
assert_eq!(data_body, "ok:/metrics", "data port must forward, not expose metrics");
assert!(!data_body.contains("waf_requests_total"), "data port leaked metrics!");
}
#[tokio::test]
async fn rate_limited_decision_is_recorded() {
let backend = start_echo_backend().await;
let proxy = Proxy::bind(&metrics_config_rate_limited(backend)).await.unwrap();
let data_addr = proxy.local_addr().unwrap();
let metrics_addr = proxy.metrics_addr().expect("metrics enabled");
tokio::spawn(proxy.run());
let client = test_client();
assert_eq!(get_status(&client, format!("http://{data_addr}/a")).await, 200);
assert_eq!(get_status(&client, format!("http://{data_addr}/b")).await, 429);
let (_, text) = get_text(&client, format!("http://{metrics_addr}/metrics")).await;
assert!(text.contains("waf_requests_total{decision=\"allowed\"} 1"), "got:\n{text}");
assert!(text.contains("waf_requests_total{decision=\"rate_limited\"} 1"), "got:\n{text}");
}
#[tokio::test]
async fn metrics_disabled_by_default_binds_no_endpoint() {
let backend = start_echo_backend().await;
let mut cfg = metrics_config(backend);
cfg.metrics.enabled = false;
let proxy = Proxy::bind(&cfg).await.unwrap();
assert!(proxy.metrics_addr().is_none(), "no metrics listener when disabled");
}