pub mod config;
pub mod metrics;
pub mod tls;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use std::time::{Instant, SystemTime};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::{Body, Bytes, Frame, Incoming};
use hyper::service::service_fn;
use hyper::{HeaderMap, Request, Response, Uri};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use crate::metrics::{Metrics, Outcome};
use tls::TlsCertSource;
use tracing::{debug, error, info, warn};
use waf_core::{
ClientIpResolver, Config, FailMode, IpSource, Normalized, RateLimitState, RequestContext,
ResilienceConfig, StateStore, WafModule,
};
use waf_detection::{
crs::CrsModule,
graphql::GraphqlModule, grpc::GrpcModule, header_injection::HeaderInjectionModule, ldap::LdapModule,
lfi_rfi::LfiRfiModule,
mail::MailModule, nosql::NosqlModule, path_traversal::PathTraversalModule,
rate_limit::RateLimitModule,
rce::RceModule, request_smuggling::RequestSmugglingModule, scanner::ScannerModule,
sqli::SqliModule, ssi::SsiModule, ssrf::SsrfModule, ssti::SstiModule, xss::XssModule,
xxe::XxeModule, ContentPrefilter,
};
use waf_normalizer::Normalizer;
use waf_pipeline::{NoopLogger, Pipeline, PipelineVerdict};
use waf_wasm::{WasmModule, WasmOptions};
pub type HyperBoxBody = BoxBody<Bytes, hyper::Error>;
pub type ModuleFactory =
dyn Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
+ Send
+ Sync;
const HOP_BY_HOP: &[&str] = &[
"connection",
"host", "keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn next_request_id() -> String {
let n = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("req-{n:016x}")
}
pub fn full_body(data: impl Into<Bytes>) -> HyperBoxBody {
Full::new(data.into())
.map_err(|never| match never {})
.boxed()
}
struct FramedBody {
data: Option<Bytes>,
trailers: Option<HeaderMap>,
}
impl Body for FramedBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
if let Some(d) = self.data.take() {
return Poll::Ready(Some(Ok(Frame::data(d))));
}
if let Some(t) = self.trailers.take() {
return Poll::Ready(Some(Ok(Frame::trailers(t))));
}
Poll::Ready(None)
}
}
fn body_with_trailers(data: Bytes, trailers: Option<HeaderMap>) -> HyperBoxBody {
match trailers {
None => full_body(data),
Some(t) => FramedBody { data: Some(data), trailers: Some(t) }
.map_err(|never| match never {})
.boxed(),
}
}
async fn collect_with_trailers<B>(body: B) -> Result<(Bytes, Option<HeaderMap>), B::Error>
where
B: Body<Data = Bytes>,
{
let collected = body.collect().await?;
let trailers = collected.trailers().cloned();
Ok((collected.to_bytes(), trailers))
}
fn is_grpc_request(parts: &hyper::http::request::Parts) -> bool {
parts
.headers
.get(hyper::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.trim_start().starts_with("application/grpc"))
.unwrap_or(false)
}
fn parse_cookies(headers: &[(String, String)]) -> Vec<(String, String)> {
headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("cookie"))
.flat_map(|(_, value)| {
value.split(';').filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
let key = parts.next()?.trim().to_string();
let val = parts.next().unwrap_or("").trim().to_string();
Some((key, val))
})
})
.collect()
}
fn build_context(
parts: &hyper::http::request::Parts,
body: &Bytes,
client_addr: SocketAddr,
ip_resolver: &ClientIpResolver,
) -> RequestContext {
let path = parts.uri.path().to_string();
let query = parts.uri.query().map(str::to_string);
let method = parts.method.to_string();
let http_version = format!("{:?}", parts.version);
let headers: Vec<(String, String)> = parts
.headers
.iter()
.filter_map(|(name, value)| {
value.to_str().ok().map(|v| (name.to_string(), v.to_string()))
})
.collect();
let cookies = parse_cookies(&headers);
let normalized = Normalized::default();
let request_id = next_request_id();
let resolved = ip_resolver.resolve(client_addr.ip(), &headers);
match resolved.source {
IpSource::FallbackMissingHeader | IpSource::FallbackMalformed => warn!(
request_id = %request_id,
peer = %client_addr.ip(),
source = ?resolved.source,
"client-IP resolution fell back to peer address"
),
IpSource::DirectPeer | IpSource::TrustedHeader => {}
}
RequestContext {
client_ip: resolved.ip,
request_id,
timestamp: SystemTime::now(),
method,
path: path.clone(),
raw_path: path,
query,
http_version,
headers,
cookies,
body: body.clone(),
normalized,
score: 0,
score_contributions: vec![],
}
}
struct Reloadable {
backend: String,
normalizer: Normalizer,
pipeline: Pipeline,
prefilter: ContentPrefilter,
ip_resolver: ClientIpResolver,
resilience: ResilienceConfig,
}
struct StaticState {
client: Client<HttpConnector, HyperBoxBody>,
grpc_client: Client<HttpConnector, HyperBoxBody>,
listen_addr: SocketAddr,
rl_state: RateLimitState,
current: RwLock<Arc<Reloadable>>,
mode: HandlerMode,
tls_acceptor: Option<TlsAcceptor>,
metrics: Arc<Metrics>,
module_factory: Option<Arc<ModuleFactory>>,
}
#[derive(Clone, Copy)]
enum HandlerMode {
Inspect,
Passthrough,
}
impl StaticState {
fn current(&self) -> Arc<Reloadable> {
self.current
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}
#[derive(Clone)]
pub struct Reloader(Arc<StaticState>);
impl Reloader {
pub fn reload_from(&self, path: &Path) -> Result<(), config::LoadError> {
let new_cfg = match config::load(path) {
Ok(c) => c,
Err(e) => {
error!(error = %e, "config reload failed; keeping current configuration");
return Err(e);
}
};
if new_cfg.proxy.listen != self.0.listen_addr {
warn!(
current = %self.0.listen_addr,
requested = %new_cfg.proxy.listen,
"proxy.listen change requires a restart; keeping the current bind address"
);
}
let extra = match &self.0.module_factory {
Some(factory) => match factory() {
Ok(modules) => modules,
Err(e) => {
error!(error = %e, "module factory failed on reload; keeping current configuration");
return Err(config::LoadError::ModuleFactory(e.to_string()));
}
},
None => Vec::new(),
};
let new_reloadable = build_reloadable(&new_cfg, self.0.rl_state.clone(), extra);
*self
.0
.current
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::new(new_reloadable);
info!("configuration reloaded");
Ok(())
}
}
fn upstream_error_response(
ctx: &RequestContext,
resilience: &ResilienceConfig,
detail: &str,
) -> Response<HyperBoxBody> {
let (status, body) = match resilience.on_upstream_error {
FailMode::FailClosed => (502, "Bad Gateway"),
FailMode::FailOpen => (503, "Service Unavailable"),
};
warn!(
request_id = %ctx.request_id,
client_ip = %ctx.client_ip,
status = status,
policy = ?resilience.on_upstream_error,
detail = detail,
"upstream error: applying on_upstream_error policy"
);
Response::builder().status(status).body(full_body(body)).unwrap()
}
fn deny_response(
ctx: &RequestContext,
verdict: PipelineVerdict,
) -> Option<(Response<HyperBoxBody>, Outcome)> {
match verdict {
PipelineVerdict::Allow => None,
PipelineVerdict::Block { rule_id, reason } => {
warn!(
request_id = %ctx.request_id,
rule_id = %rule_id,
reason = %reason,
score = ctx.score,
"request blocked"
);
Some((
Response::builder()
.status(403)
.body(full_body("Forbidden"))
.unwrap(),
Outcome::Blocked,
))
}
PipelineVerdict::Reject { rule_id, reason, status, retry_after } => {
warn!(
request_id = %ctx.request_id,
rule_id = %rule_id,
reason = %reason,
status = status,
"request rejected"
);
let (body, outcome) = match status {
429 => ("Too Many Requests", Outcome::RateLimited),
400 => ("Bad Request", Outcome::BadRequest),
_ => ("Rejected", Outcome::BadRequest),
};
let mut builder = Response::builder().status(status);
if let Some(secs) = retry_after {
builder = builder.header("retry-after", secs.to_string());
}
Some((builder.body(full_body(body)).unwrap(), outcome))
}
}
}
async fn try_forward(
req: Request<Incoming>,
state: &StaticState,
client_addr: SocketAddr,
) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
let rel = state.current();
let (parts, body) = req.into_parts();
let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
let mut ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
let connection_verdict = rel.pipeline.run_connection(&mut ctx);
if let Some(denied) = deny_response(&ctx, connection_verdict) {
return Ok(denied);
}
let normalized_ok = match rel.normalizer.normalize(&mut ctx) {
Ok(()) => true,
Err(e) => match rel.resilience.on_parser_limit {
FailMode::FailClosed => {
warn!(
request_id = %ctx.request_id,
error = %e,
policy = ?FailMode::FailClosed,
"normalization failed: rejecting (on_parser_limit)"
);
return Ok((
Response::builder()
.status(400)
.body(full_body("Bad Request"))
.unwrap(),
Outcome::BadRequest,
));
}
FailMode::FailOpen => {
warn!(
request_id = %ctx.request_id,
error = %e,
policy = ?FailMode::FailOpen,
"normalization failed: forwarding UNINSPECTED (on_parser_limit)"
);
false
}
},
};
let path_and_query = parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/")
.to_string();
info!(
request_id = %ctx.request_id,
method = %ctx.method,
path = %path_and_query,
client_ip = %ctx.client_ip,
"→ request"
);
if normalized_ok {
let inspect = rel.prefilter.is_candidate(&ctx);
let inspection_verdict = rel.pipeline.run_inspection_gated(&mut ctx, inspect);
if let Some(denied) = deny_response(&ctx, inspection_verdict) {
return Ok(denied);
}
}
forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
}
#[allow(clippy::too_many_arguments)]
async fn forward_to_backend(
state: &StaticState,
rel: &Reloadable,
parts: &hyper::http::request::Parts,
path_and_query: &str,
body_bytes: Bytes,
req_trailers: Option<HeaderMap>,
client_addr: SocketAddr,
ctx: &RequestContext,
) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
let backend_uri: Uri = format!("{}{}", rel.backend, path_and_query).parse()?;
let is_grpc = is_grpc_request(parts);
let mut builder = Request::builder()
.method(parts.method.clone())
.uri(backend_uri);
for (name, value) in &parts.headers {
if !HOP_BY_HOP.contains(&name.as_str()) {
builder = builder.header(name, value);
}
}
builder = builder.header("x-forwarded-for", client_addr.ip().to_string());
builder = builder.header("x-request-id", ctx.request_id.as_str());
if is_grpc {
builder = builder.header("te", "trailers");
}
let (client, fwd_body) = if is_grpc {
(&state.grpc_client, body_with_trailers(body_bytes, req_trailers))
} else {
(&state.client, full_body(body_bytes))
};
let fwd_req = builder.body(fwd_body)?;
let upstream = tokio::time::timeout(rel.resilience.upstream_timeout(), async {
let resp = client.request(fwd_req).await?;
let (resp_parts, resp_body) = resp.into_parts();
let (resp_bytes, resp_trailers) = collect_with_trailers(resp_body).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((resp_parts, resp_bytes, resp_trailers))
})
.await;
let (resp_parts, resp_bytes, resp_trailers) = match upstream {
Ok(Ok(triple)) => triple,
Ok(Err(e)) => {
return Ok((
upstream_error_response(ctx, &rel.resilience, &e.to_string()),
Outcome::UpstreamError,
))
}
Err(_elapsed) => {
return Ok((
upstream_error_response(ctx, &rel.resilience, "upstream timeout"),
Outcome::UpstreamError,
))
}
};
info!(
request_id = %ctx.request_id,
status = %resp_parts.status,
score = ctx.score,
"← response"
);
Ok((
Response::from_parts(resp_parts, body_with_trailers(resp_bytes, resp_trailers)),
Outcome::Allowed,
))
}
async fn try_passthrough(
req: Request<Incoming>,
state: &StaticState,
client_addr: SocketAddr,
) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
let rel = state.current();
let (parts, body) = req.into_parts();
let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
let ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
let path_and_query = parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/")
.to_string();
forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
}
async fn handle(
req: Request<Incoming>,
state: Arc<StaticState>,
client_addr: SocketAddr,
) -> Result<Response<HyperBoxBody>, Infallible> {
let start = Instant::now();
let result = match state.mode {
HandlerMode::Inspect => try_forward(req, &state, client_addr).await,
HandlerMode::Passthrough => try_passthrough(req, &state, client_addr).await,
};
let (resp, outcome) = match result {
Ok((resp, outcome)) => (resp, outcome),
Err(e) => {
error!(error = %e, client_ip = %client_addr.ip(), "forwarding error");
let resp = Response::builder()
.status(502)
.body(full_body("Bad Gateway"))
.unwrap();
(resp, Outcome::InternalError)
}
};
state.metrics.record(outcome, start.elapsed());
Ok(resp)
}
pub struct Proxy {
listener: TcpListener,
state: Arc<StaticState>,
metrics_listener: Option<TcpListener>,
}
fn build_modules(config: &Config, rl_state: &RateLimitState) -> Vec<Box<dyn WafModule>> {
let mut modules: Vec<Box<dyn WafModule>> = vec![Box::new(NoopLogger)];
if config.modules.request_smuggling.enabled {
modules.push(Box::new(RequestSmugglingModule::new()));
}
if config.rate_limit.enabled {
modules.push(Box::new(RateLimitModule::with_state(rl_state.clone())));
}
if config.modules.sqli.enabled {
modules.push(Box::new(SqliModule::new()));
}
if config.modules.xss.enabled {
modules.push(Box::new(XssModule::new()));
}
if config.modules.path_traversal.enabled {
modules.push(Box::new(PathTraversalModule::new()));
}
if config.modules.rce.enabled {
modules.push(Box::new(RceModule::new()));
}
if config.modules.lfi_rfi.enabled {
modules.push(Box::new(LfiRfiModule::new()));
}
if config.modules.ssrf.enabled {
modules.push(Box::new(SsrfModule::new()));
}
if config.modules.ldap.enabled {
modules.push(Box::new(LdapModule::new()));
}
if config.modules.nosql.enabled {
modules.push(Box::new(NosqlModule::new()));
}
if config.modules.mail.enabled {
modules.push(Box::new(MailModule::new()));
}
if config.modules.ssti.enabled {
modules.push(Box::new(SstiModule::new()));
}
if config.modules.scanner.enabled {
modules.push(Box::new(ScannerModule::new()));
}
if config.modules.ssi.enabled {
modules.push(Box::new(SsiModule::new()));
}
if config.modules.xxe.enabled {
modules.push(Box::new(XxeModule::new()));
}
if config.modules.header_injection.enabled {
modules.push(Box::new(HeaderInjectionModule::new()));
}
if config.modules.graphql.enabled {
modules.push(Box::new(GraphqlModule::new()));
}
if config.modules.grpc.enabled {
modules.push(Box::new(GrpcModule::new()));
}
if config.modules.crs.enabled {
modules.push(Box::new(load_crs_module(&config.modules.crs.files)));
}
if config.modules.wasm.enabled {
for plugin in &config.modules.wasm.plugins {
if let Some(m) = load_wasm_plugin(plugin, &config.modules.wasm) {
modules.push(Box::new(m));
}
}
}
modules
}
fn load_wasm_plugin(
plugin: &waf_core::WasmPluginConfig,
cfg: &waf_core::WasmConfig,
) -> Option<WasmModule> {
let bytes = match std::fs::read(&plugin.path) {
Ok(b) => b,
Err(e) => {
error!(file = %plugin.path, error = %e, "WASM: cannot read plugin (skipped)");
return None;
}
};
let name = plugin_name(&plugin.path);
let opts = WasmOptions {
pool_size: cfg.pool_size,
fuel_per_request: cfg.fuel_per_request,
max_memory_bytes: cfg.max_memory_bytes,
checkout_timeout: std::time::Duration::from_millis(cfg.checkout_timeout_ms),
};
let config_bytes = plugin.config.as_deref().unwrap_or("").as_bytes();
match WasmModule::from_bytes(&name, &bytes, config_bytes, &opts) {
Ok((module, report)) => {
info!(plugin = %name, "{}", report.summary());
Some(module)
}
Err(e) => {
error!(file = %plugin.path, error = %e, "WASM: plugin failed to load (skipped)");
None
}
}
}
fn plugin_name(path: &str) -> String {
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("plugin")
.to_string()
}
fn load_crs_module(files: &[String]) -> CrsModule {
let mut combined = String::new();
for path in files {
match std::fs::read_to_string(path) {
Ok(text) => {
combined.push_str(&text);
combined.push('\n');
}
Err(e) => error!(file = %path, error = %e, "CRS import: cannot read file (skipped)"),
}
}
let module = CrsModule::from_source(&combined);
info!(files = files.len(), "{}", module.report());
if !module.skipped().is_empty() {
warn!(
skipped = module.skipped().len(),
"CRS import: some rules fall outside the supported subset (see debug logs for reasons)"
);
for s in module.skipped() {
debug!(id = ?s.id, line = s.line_no, reason = %s.reason, "CRS import: rule skipped");
}
}
module
}
fn build_reloadable(
config: &Config,
rl_state: RateLimitState,
extra: Vec<Box<dyn WafModule>>,
) -> Reloadable {
let mut modules = build_modules(config, &rl_state);
modules.extend(extra);
let pipeline = Pipeline::new(config, modules);
if config.waf.paranoia_level > waf_detection::HIGHEST_RULE_PARANOIA {
warn!(
paranoia_level = config.waf.paranoia_level,
highest_rule_paranoia = waf_detection::HIGHEST_RULE_PARANOIA,
"paranoia_level exceeds the highest existing rule paranoia: no additional rules are activated"
);
}
let ip_resolver = ClientIpResolver::from_config(&config.network);
if ip_resolver.trusted_count() < config.network.trusted_proxies.len() {
warn!(
configured = config.network.trusted_proxies.len(),
valid = ip_resolver.trusted_count(),
"some trusted_proxies CIDR entries were invalid and skipped"
);
}
Reloadable {
backend: config.proxy.backend.trim_end_matches('/').to_string(),
normalizer: Normalizer::new(&config.limits),
pipeline,
prefilter: ContentPrefilter::new(config.waf.paranoia_level),
ip_resolver,
resilience: config.resilience,
}
}
impl Proxy {
pub async fn bind(config: &Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Self::builder(config).build().await
}
pub fn builder(config: &Config) -> ProxyBuilder<'_> {
ProxyBuilder {
config,
modules: Vec::new(),
state_store: None,
cert_source: None,
module_factory: None,
mode: HandlerMode::Inspect,
}
}
#[doc(hidden)]
pub async fn bind_with_modules(
config: &Config,
extra: Vec<Box<dyn WafModule>>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Self::bind_inner(config, extra, HandlerMode::Inspect, None, None, None).await
}
#[doc(hidden)]
pub async fn bind_passthrough(
config: &Config,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Self::bind_inner(config, Vec::new(), HandlerMode::Passthrough, None, None, None).await
}
async fn bind_inner(
config: &Config,
extra: Vec<Box<dyn WafModule>>,
mode: HandlerMode,
state_store: Option<RateLimitState>,
cert_source: Option<Arc<dyn TlsCertSource>>,
module_factory: Option<Arc<ModuleFactory>>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(config.proxy.listen).await?;
let listen_addr = listener.local_addr()?;
let client: Client<HttpConnector, HyperBoxBody> =
Client::builder(TokioExecutor::new()).build(HttpConnector::new());
let grpc_client: Client<HttpConnector, HyperBoxBody> =
Client::builder(TokioExecutor::new()).http2_only(true).build(HttpConnector::new());
let tls_acceptor = tls::acceptor_from_source(&config.tls, cert_source)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
if tls_acceptor.is_some() {
info!(listen = %listen_addr, alpn = ?config.tls.alpn, "TLS termination enabled");
}
let rl_state = state_store
.unwrap_or_else(|| RateLimitState::in_memory(config.rate_limit.max_tracked_keys));
let mut extra_total = extra;
if let Some(factory) = &module_factory {
extra_total.extend(factory()?);
}
let reloadable = build_reloadable(config, rl_state.clone(), extra_total);
let metrics = Arc::new(Metrics::new());
let metrics_listener = if config.metrics.enabled {
let l = TcpListener::bind(config.metrics.listen).await?;
info!(listen = %l.local_addr()?, "metrics endpoint enabled (/metrics)");
Some(l)
} else {
None
};
Ok(Self {
listener,
state: Arc::new(StaticState {
client,
grpc_client,
listen_addr,
rl_state,
current: RwLock::new(Arc::new(reloadable)),
mode,
tls_acceptor,
metrics,
module_factory,
}),
metrics_listener,
})
}
pub fn reloader(&self) -> Reloader {
Reloader(Arc::clone(&self.state))
}
pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
self.listener.local_addr()
}
pub fn metrics_addr(&self) -> Option<SocketAddr> {
self.metrics_listener.as_ref().and_then(|l| l.local_addr().ok())
}
pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(metrics_listener) = self.metrics_listener {
let metrics = Arc::clone(&self.state.metrics);
tokio::spawn(serve_metrics(metrics_listener, metrics));
}
loop {
let (stream, client_addr) = self.listener.accept().await?;
let state = Arc::clone(&self.state);
tokio::spawn(async move {
match state.tls_acceptor.clone() {
Some(acceptor) => match acceptor.accept(stream).await {
Ok(tls_stream) => {
serve_connection(TokioIo::new(tls_stream), state, client_addr).await;
}
Err(e) => {
warn!(error = %e, client_ip = %client_addr.ip(), "TLS handshake error");
}
},
None => {
serve_connection(TokioIo::new(stream), state, client_addr).await;
}
}
});
}
}
}
pub struct ProxyBuilder<'a> {
config: &'a Config,
modules: Vec<Box<dyn WafModule>>,
state_store: Option<RateLimitState>,
cert_source: Option<Arc<dyn TlsCertSource>>,
module_factory: Option<Arc<ModuleFactory>>,
mode: HandlerMode,
}
impl<'a> ProxyBuilder<'a> {
pub fn modules(mut self, modules: Vec<Box<dyn WafModule>>) -> Self {
self.modules = modules;
self
}
pub fn add_module(mut self, module: Box<dyn WafModule>) -> Self {
self.modules.push(module);
self
}
pub fn state_store(mut self, store: Arc<dyn StateStore>) -> Self {
self.state_store = Some(RateLimitState::with_store(store));
self
}
pub fn cert_source(mut self, source: Arc<dyn TlsCertSource>) -> Self {
self.cert_source = Some(source);
self
}
pub fn module_factory<F>(mut self, factory: F) -> Self
where
F: Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
+ Send
+ Sync
+ 'static,
{
self.module_factory = Some(Arc::new(factory));
self
}
pub async fn build(self) -> Result<Proxy, Box<dyn std::error::Error + Send + Sync>> {
Proxy::bind_inner(
self.config,
self.modules,
self.mode,
self.state_store,
self.cert_source,
self.module_factory,
)
.await
}
}
async fn serve_connection<I>(io: I, state: Arc<StaticState>, client_addr: SocketAddr)
where
I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
{
let svc = service_fn(move |req| {
let state = Arc::clone(&state);
handle(req, state, client_addr)
});
if let Err(e) = auto::Builder::new(TokioExecutor::new())
.serve_connection(io, svc)
.await
{
warn!(error = %e, client_ip = %client_addr.ip(), "connection error");
}
}
async fn serve_metrics(listener: TcpListener, metrics: Arc<Metrics>) {
loop {
let Ok((stream, _)) = listener.accept().await else { continue };
let metrics = Arc::clone(&metrics);
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| {
let metrics = Arc::clone(&metrics);
async move { Ok::<_, Infallible>(metrics_response(&req, &metrics)) }
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
}
}
fn metrics_response(req: &Request<Incoming>, metrics: &Metrics) -> Response<HyperBoxBody> {
if req.method() == hyper::Method::GET && req.uri().path() == "/metrics" {
Response::builder()
.status(200)
.header("content-type", "text/plain; version=0.0.4; charset=utf-8")
.body(full_body(metrics.render()))
.unwrap()
} else {
Response::builder().status(404).body(full_body("Not Found")).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use waf_core::WafMode;
#[test]
fn hop_by_hop_includes_connection_and_host() {
assert!(HOP_BY_HOP.contains(&"connection"));
assert!(HOP_BY_HOP.contains(&"host"));
assert!(HOP_BY_HOP.contains(&"transfer-encoding"));
}
#[test]
fn hop_by_hop_excludes_regular_headers() {
assert!(!HOP_BY_HOP.contains(&"content-type"));
assert!(!HOP_BY_HOP.contains(&"authorization"));
assert!(!HOP_BY_HOP.contains(&"x-custom-header"));
}
#[test]
fn config_parses_from_toml() {
let raw = r#"
[proxy]
listen = "127.0.0.1:8080"
backend = "http://localhost:3000"
[waf]
mode = "detection-only"
block_threshold = 10
"#;
let config: Config = toml::from_str(raw).unwrap();
assert_eq!(config.proxy.backend, "http://localhost:3000");
assert_eq!(config.waf.mode, WafMode::DetectionOnly);
assert_eq!(config.waf.block_threshold, 10);
}
#[test]
fn config_uses_default_block_threshold_when_omitted() {
let raw = r#"
[proxy]
listen = "127.0.0.1:8080"
backend = "http://localhost:3000"
[waf]
mode = "detection-only"
"#;
let config: Config = toml::from_str(raw).unwrap();
assert_eq!(config.waf.block_threshold, 5);
}
#[test]
fn config_parses_network_section() {
let raw = r#"
[proxy]
listen = "127.0.0.1:8080"
backend = "http://localhost:3000"
[waf]
mode = "blocking"
[network]
trusted_proxies = ["10.0.0.0/8", "::1"]
client_ip_header = "X-Forwarded-For"
trusted_hops = 2
"#;
let config: Config = toml::from_str(raw).unwrap();
assert_eq!(config.network.trusted_proxies, vec!["10.0.0.0/8", "::1"]);
assert_eq!(config.network.client_ip_header, "X-Forwarded-For");
assert_eq!(config.network.trusted_hops, 2);
}
#[test]
fn config_network_defaults_to_failsafe_when_absent() {
let raw = r#"
[proxy]
listen = "127.0.0.1:8080"
backend = "http://localhost:3000"
[waf]
mode = "detection-only"
"#;
let config: Config = toml::from_str(raw).unwrap();
assert!(config.network.trusted_proxies.is_empty());
assert_eq!(config.network.trusted_hops, 1);
assert_eq!(config.network.client_ip_header, "x-forwarded-for".to_string());
}
#[test]
fn config_rejects_unknown_mode() {
let raw = r#"
[proxy]
listen = "127.0.0.1:8080"
backend = "http://localhost:3000"
[waf]
mode = "unknown-mode"
"#;
assert!(toml::from_str::<Config>(raw).is_err());
}
#[test]
fn parse_cookies_splits_on_semicolon() {
let headers = vec![("cookie".to_string(), "session=abc; user=123".to_string())];
let cookies = parse_cookies(&headers);
assert_eq!(cookies.len(), 2);
assert!(cookies.contains(&("session".to_string(), "abc".to_string())));
assert!(cookies.contains(&("user".to_string(), "123".to_string())));
}
#[test]
fn parse_cookies_handles_missing_value() {
let headers = vec![("cookie".to_string(), "flag=; token=xyz".to_string())];
let cookies = parse_cookies(&headers);
assert!(cookies.contains(&("flag".to_string(), "".to_string())));
assert!(cookies.contains(&("token".to_string(), "xyz".to_string())));
}
#[test]
fn parse_cookies_handles_empty_header_list() {
assert!(parse_cookies(&[]).is_empty());
}
#[test]
fn request_id_is_unique_per_call() {
let id1 = next_request_id();
let id2 = next_request_id();
assert_ne!(id1, id2);
assert!(id1.starts_with("req-"));
}
}