Skip to main content

waf_proxy/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod config;
5pub mod metrics;
6pub mod tls;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::path::Path;
11use std::pin::Pin;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, RwLock};
14use std::task::{Context, Poll};
15use std::time::{Instant, SystemTime};
16
17use http_body_util::combinators::BoxBody;
18use http_body_util::{BodyExt, Full};
19use hyper::body::{Body, Bytes, Frame, Incoming};
20use hyper::service::service_fn;
21use hyper::{HeaderMap, Request, Response, Uri};
22use hyper_util::client::legacy::connect::HttpConnector;
23use hyper_util::client::legacy::Client;
24use hyper_util::rt::{TokioExecutor, TokioIo};
25use hyper_util::server::conn::auto;
26use tokio::net::TcpListener;
27use tokio_rustls::TlsAcceptor;
28
29use crate::metrics::{Metrics, Outcome};
30use tls::TlsCertSource;
31use tracing::{debug, error, info, warn};
32
33use waf_core::{
34    ClientIpResolver, Config, FailMode, IpSource, Normalized, RateLimitState, RequestContext,
35    ResilienceConfig, StateStore, WafModule,
36};
37use waf_detection::{
38    crs::CrsModule,
39    graphql::GraphqlModule, grpc::GrpcModule, header_injection::HeaderInjectionModule, ldap::LdapModule,
40    lfi_rfi::LfiRfiModule,
41    mail::MailModule, nosql::NosqlModule, path_traversal::PathTraversalModule,
42    rate_limit::RateLimitModule,
43    rce::RceModule, request_smuggling::RequestSmugglingModule, scanner::ScannerModule,
44    sqli::SqliModule, ssi::SsiModule, ssrf::SsrfModule, ssti::SstiModule, xss::XssModule,
45    xxe::XxeModule, ContentPrefilter,
46};
47use waf_normalizer::Normalizer;
48use waf_pipeline::{NoopLogger, Pipeline, PipelineVerdict};
49use waf_wasm::{WasmModule, WasmOptions};
50
51pub type HyperBoxBody = BoxBody<Bytes, hyper::Error>;
52
53/// A factory that (re)builds the injected detection modules. Called ONCE at bind and again
54/// on every config reload — so modules injected by an embedder (BOUNDARY §4) SURVIVE a
55/// SIGHUP and are re-`init`'d, instead of being dropped (the pre-0.3 behaviour). It returns
56/// a `Result` as a UNIT: on error the whole reload is aborted and the last-good `Reloadable`
57/// (which still holds the working modules) is kept — the modules are never dropped on a
58/// failed rebuild. A boxed closure so an embedder can capture its own (enterprise) config.
59pub type ModuleFactory =
60    dyn Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
61        + Send
62        + Sync;
63
64/// Headers that must not be forwarded verbatim to the backend (RFC 7230).
65const HOP_BY_HOP: &[&str] = &[
66    "connection",
67    "host", // re-set by hyper from the target URI
68    "keep-alive",
69    "proxy-authenticate",
70    "proxy-authorization",
71    "te",
72    "trailers",
73    "transfer-encoding",
74    "upgrade",
75];
76
77static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
78
79fn next_request_id() -> String {
80    let n = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
81    format!("req-{n:016x}")
82}
83
84pub fn full_body(data: impl Into<Bytes>) -> HyperBoxBody {
85    Full::new(data.into())
86        .map_err(|never| match never {})
87        .boxed()
88}
89
90/// A buffered body that emits one DATA frame, then one TRAILERS frame. A plain `Full`
91/// cannot carry trailers; gRPC puts its status in HTTP/2 trailers (`grpc-status`/
92/// `grpc-message`), so relaying them requires this. Used only when trailers are present —
93/// the non-gRPC path keeps using `full_body` (byte-identical to before).
94struct FramedBody {
95    data: Option<Bytes>,
96    trailers: Option<HeaderMap>,
97}
98
99impl Body for FramedBody {
100    type Data = Bytes;
101    type Error = Infallible;
102
103    fn poll_frame(
104        mut self: Pin<&mut Self>,
105        _cx: &mut Context<'_>,
106    ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
107        if let Some(d) = self.data.take() {
108            return Poll::Ready(Some(Ok(Frame::data(d))));
109        }
110        if let Some(t) = self.trailers.take() {
111            return Poll::Ready(Some(Ok(Frame::trailers(t))));
112        }
113        Poll::Ready(None)
114    }
115}
116
117/// Box a buffered body, attaching `trailers` when present. With no trailers this is exactly
118/// `full_body` (so the non-gRPC datapath is unchanged); with trailers it is a `FramedBody`.
119fn body_with_trailers(data: Bytes, trailers: Option<HeaderMap>) -> HyperBoxBody {
120    match trailers {
121        None => full_body(data),
122        Some(t) => FramedBody { data: Some(data), trailers: Some(t) }
123            .map_err(|never| match never {})
124            .boxed(),
125    }
126}
127
128/// Collect a body into `(bytes, trailers)` — the trailer-preserving alternative to
129/// `collect().to_bytes()`. Keeps the buffered model (so the body is still inspectable)
130/// while not discarding the trailers that follow it (Step-0 invariant).
131async fn collect_with_trailers<B>(body: B) -> Result<(Bytes, Option<HeaderMap>), B::Error>
132where
133    B: Body<Data = Bytes>,
134{
135    let collected = body.collect().await?;
136    let trailers = collected.trailers().cloned();
137    Ok((collected.to_bytes(), trailers))
138}
139
140/// A gRPC request, by Content-Type (`application/grpc`, `+proto`, `-web`, …). Such requests
141/// are forwarded over h2c with their trailers relayed; everything else takes the unchanged
142/// h1 path.
143fn is_grpc_request(parts: &hyper::http::request::Parts) -> bool {
144    parts
145        .headers
146        .get(hyper::header::CONTENT_TYPE)
147        .and_then(|v| v.to_str().ok())
148        .map(|ct| ct.trim_start().starts_with("application/grpc"))
149        .unwrap_or(false)
150}
151
152fn parse_cookies(headers: &[(String, String)]) -> Vec<(String, String)> {
153    headers
154        .iter()
155        .filter(|(name, _)| name.eq_ignore_ascii_case("cookie"))
156        .flat_map(|(_, value)| {
157            value.split(';').filter_map(|pair| {
158                let mut parts = pair.splitn(2, '=');
159                let key = parts.next()?.trim().to_string();
160                let val = parts.next().unwrap_or("").trim().to_string();
161                Some((key, val))
162            })
163        })
164        .collect()
165}
166
167fn build_context(
168    parts: &hyper::http::request::Parts,
169    body: &Bytes,
170    client_addr: SocketAddr,
171    ip_resolver: &ClientIpResolver,
172) -> RequestContext {
173    let path = parts.uri.path().to_string();
174    let query = parts.uri.query().map(str::to_string);
175    let method = parts.method.to_string();
176    let http_version = format!("{:?}", parts.version);
177
178    let headers: Vec<(String, String)> = parts
179        .headers
180        .iter()
181        .filter_map(|(name, value)| {
182            value.to_str().ok().map(|v| (name.to_string(), v.to_string()))
183        })
184        .collect();
185
186    let cookies = parse_cookies(&headers);
187
188    let normalized = Normalized::default();
189
190    // Resolve the real client IP ONCE here: rate limiting, logging and future
191    // Geo/IP-reputation all read it back from `ctx.client_ip` (single source of
192    // truth). A fallback behind a trusted proxy means a spoofing attempt or a
193    // misconfigured upstream — log it.
194    let request_id = next_request_id();
195    let resolved = ip_resolver.resolve(client_addr.ip(), &headers);
196    match resolved.source {
197        IpSource::FallbackMissingHeader | IpSource::FallbackMalformed => warn!(
198            request_id = %request_id,
199            peer = %client_addr.ip(),
200            source = ?resolved.source,
201            "client-IP resolution fell back to peer address"
202        ),
203        IpSource::DirectPeer | IpSource::TrustedHeader => {}
204    }
205
206    RequestContext {
207        client_ip: resolved.ip,
208        request_id,
209        timestamp: SystemTime::now(),
210        method,
211        path: path.clone(),
212        raw_path: path,
213        query,
214        http_version,
215        headers,
216        cookies,
217        body: body.clone(),
218        normalized,
219        score: 0,
220        score_contributions: vec![],
221    }
222}
223
224/// Config-derived state, rebuilt as a unit on every hot reload and swapped
225/// atomically. A request loads either the entire old or the entire new value —
226/// never a mix of recompiled rules and stale thresholds.
227struct Reloadable {
228    backend: String,
229    normalizer: Normalizer,
230    pipeline: Pipeline,
231    /// Fast-path skip prefilter (Fase 7 / Pillar 3). Built here, in the SAME unit as
232    /// `pipeline`, from the same rule sources and the same `paranoia_level` snapshot,
233    /// so a reload regenerates both together — they can never drift apart.
234    prefilter: ContentPrefilter,
235    ip_resolver: ClientIpResolver,
236    resilience: ResilienceConfig,
237}
238
239/// Process-lifetime state that survives reloads:
240/// - `client`: the hyper connection pool (kept warm);
241/// - `listen_addr`: the bound address (restart-required if it changes);
242/// - `rl_state`: the rate-limiter token buckets (NOT reset by a reload, so a
243///   reload cannot be used to clear an attacker's throttle);
244/// - `current`: the atomically-swappable `Reloadable`.
245struct StaticState {
246    client: Client<HttpConnector, HyperBoxBody>,
247    /// A SEPARATE h2c (HTTP/2 prior-knowledge) client used ONLY for gRPC targets. Kept
248    /// distinct from `client` on purpose: flipping the general client to `http2_only` would
249    /// break all existing h1 forwarding — gRPC needs end-to-end h2, the rest stays h1.
250    grpc_client: Client<HttpConnector, HyperBoxBody>,
251    listen_addr: SocketAddr,
252    rl_state: RateLimitState,
253    current: RwLock<Arc<Reloadable>>,
254    mode: HandlerMode,
255    /// Inbound TLS terminator (Phase 12). `Some` ⇒ the listener serves ONLY TLS (h1/h2
256    /// by ALPN); `None` ⇒ cleartext (h1 + h2c). Built once at bind; a required-but-broken
257    /// cert fails the bind, so there is no runtime path that downgrades to cleartext.
258    tls_acceptor: Option<TlsAcceptor>,
259    /// Process-lifetime metrics (B1). Survives reloads like the rate-limit store. Recorded
260    /// once per request in `handle`; served by the metrics task (`Proxy::metrics_listener`).
261    metrics: Arc<Metrics>,
262    /// Factory that rebuilds the injected (embedder) modules on every reload (core 0.3). Process
263    /// lifetime, so `Reloader::reload_from` can re-run it in place of the pre-0.3 `Vec::new()` —
264    /// this is what makes `.add_module`-style injected modules survive a SIGHUP. `None` ⇒ no
265    /// injected modules to carry across a reload (the default OPEN build).
266    module_factory: Option<Arc<ModuleFactory>>,
267}
268
269/// Which request handler the accept loop dispatches to. `Inspect` is the ONLY mode a
270/// configured WAF ever uses (every public `bind*` sets it). `Passthrough` is a
271/// `#[doc(hidden)]` bench seam set ONLY by `bind_passthrough` — no `config.toml` field
272/// reaches it (that is the line separating a bench seam from a production bypass flag).
273/// It exists so the Fase 9 (c) load-test can measure the WAF-overhead delta against the
274/// SAME `forward_to_backend` the inspecting path uses.
275#[derive(Clone, Copy)]
276enum HandlerMode {
277    Inspect,
278    Passthrough,
279}
280
281impl StaticState {
282    /// Load the current config snapshot: take the read lock just long enough to
283    /// clone the `Arc`, then release it (never held across `.await`). Poisoning is
284    /// recovered (`into_inner`) because the only writer holds the lock solely for a
285    /// pointer assignment that cannot panic — so the data is never left invalid.
286    fn current(&self) -> Arc<Reloadable> {
287        self.current
288            .read()
289            .unwrap_or_else(|poisoned| poisoned.into_inner())
290            .clone()
291    }
292}
293
294/// Handle that can hot-reload a running proxy's configuration. Obtained via
295/// `Proxy::reloader()`; cheap to clone (an `Arc`). Used by the SIGHUP task in the
296/// binary and directly by tests.
297#[derive(Clone)]
298pub struct Reloader(Arc<StaticState>);
299
300impl Reloader {
301    /// Re-read, validate (reusing Pillar-1 `config::load`) and atomically swap.
302    /// On any error the current configuration is KEPT and the error is logged —
303    /// a failed reload never degrades a working WAF.
304    pub fn reload_from(&self, path: &Path) -> Result<(), config::LoadError> {
305        let new_cfg = match config::load(path) {
306            Ok(c) => c,
307            Err(e) => {
308                error!(error = %e, "config reload failed; keeping current configuration");
309                return Err(e);
310            }
311        };
312
313        // Restart-required field: the socket is already bound.
314        if new_cfg.proxy.listen != self.0.listen_addr {
315            warn!(
316                current = %self.0.listen_addr,
317                requested = %new_cfg.proxy.listen,
318                "proxy.listen change requires a restart; keeping the current bind address"
319            );
320        }
321
322        // Rebuild the injected (embedder) modules via the factory (core 0.3). Pre-0.3 this
323        // passed `Vec::new()`, silently dropping every `.add_module` module on a reload. The
324        // factory is fallible as a UNIT: if it errors (e.g. an enterprise schema file became
325        // invalid on disk), the whole reload is ABORTED and the current `Reloadable` — which
326        // still holds the working modules — is kept, exactly like a rejected config. No
327        // partial rebuild, no unprotected window, and the modules are never dropped on error.
328        let extra = match &self.0.module_factory {
329            Some(factory) => match factory() {
330                Ok(modules) => modules,
331                Err(e) => {
332                    error!(error = %e, "module factory failed on reload; keeping current configuration");
333                    return Err(config::LoadError::ModuleFactory(e.to_string()));
334                }
335            },
336            None => Vec::new(),
337        };
338
339        // Rebuild ALL config-derived state (rules recompiled, CIDR re-parsed),
340        // reusing the shared rate-limit buckets so the throttle state survives.
341        let new_reloadable = build_reloadable(&new_cfg, self.0.rl_state.clone(), extra);
342
343        // Atomic swap. The write section is a single pointer assignment that
344        // cannot panic, so the lock is never poisoned by this path; recover
345        // defensively anyway so a foreign poison can't wedge reloads.
346        *self
347            .0
348            .current
349            .write()
350            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::new(new_reloadable);
351        info!("configuration reloaded");
352        Ok(())
353    }
354}
355
356/// Build an upstream-error response per `on_upstream_error`: 502 (fail_closed,
357/// definitive gateway failure) or 503 (fail_open, retryable). Note: "fail_open"
358/// here does NOT pass traffic through — there is no origin to reach — it only
359/// softens the status to a retryable one. Always logged (critical operational event).
360fn upstream_error_response(
361    ctx: &RequestContext,
362    resilience: &ResilienceConfig,
363    detail: &str,
364) -> Response<HyperBoxBody> {
365    let (status, body) = match resilience.on_upstream_error {
366        FailMode::FailClosed => (502, "Bad Gateway"),
367        FailMode::FailOpen => (503, "Service Unavailable"),
368    };
369    warn!(
370        request_id = %ctx.request_id,
371        client_ip = %ctx.client_ip,
372        status = status,
373        policy = ?resilience.on_upstream_error,
374        detail = detail,
375        "upstream error: applying on_upstream_error policy"
376    );
377    Response::builder().status(status).body(full_body(body)).unwrap()
378}
379
380/// Serialize the per-rule score breakdown to a compact JSON array for the decision-log
381/// (`score_contributions` field). This is the data the enterprise control-plane drill-down
382/// (§7) reconstructs a blocked verdict from — emitted only on the already-logged denied path,
383/// never per benign request. Serialization of this plain data cannot realistically fail; an
384/// empty array on the theoretical error keeps the log line well-formed.
385fn contributions_json(ctx: &RequestContext) -> String {
386    serde_json::to_string(&ctx.score_contributions).unwrap_or_else(|_| "[]".to_string())
387}
388
389/// Map a denying pipeline verdict to an HTTP response (403 for Block, the
390/// carried status — e.g. 429 + `Retry-After` — for Reject). `Allow` → `None`.
391fn deny_response(
392    ctx: &RequestContext,
393    verdict: PipelineVerdict,
394) -> Option<(Response<HyperBoxBody>, Outcome)> {
395    match verdict {
396        PipelineVerdict::Allow => None,
397        PipelineVerdict::Block { rule_id, reason } => {
398            warn!(
399                request_id = %ctx.request_id,
400                rule_id = %rule_id,
401                reason = %reason,
402                score = ctx.score,
403                score_contributions = %contributions_json(ctx),
404                "request blocked"
405            );
406            Some((
407                Response::builder()
408                    .status(403)
409                    .body(full_body("Forbidden"))
410                    .unwrap(),
411                Outcome::Blocked,
412            ))
413        }
414        PipelineVerdict::Reject { rule_id, reason, status, retry_after } => {
415            warn!(
416                request_id = %ctx.request_id,
417                rule_id = %rule_id,
418                reason = %reason,
419                status = status,
420                score_contributions = %contributions_json(ctx),
421                "request rejected"
422            );
423            // Reason phrase + metric outcome by status: 429 rate-limit, 400 illegal framing
424            // (request smuggling). Block (403 detection) is a separate arm above.
425            let (body, outcome) = match status {
426                429 => ("Too Many Requests", Outcome::RateLimited),
427                400 => ("Bad Request", Outcome::BadRequest),
428                _ => ("Rejected", Outcome::BadRequest),
429            };
430            let mut builder = Response::builder().status(status);
431            if let Some(secs) = retry_after {
432                builder = builder.header("retry-after", secs.to_string());
433            }
434            Some((builder.body(full_body(body)).unwrap(), outcome))
435        }
436    }
437}
438
439async fn try_forward(
440    req: Request<Incoming>,
441    state: &StaticState,
442    client_addr: SocketAddr,
443) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
444    // Load the current config snapshot ONCE per request (atomic): the whole
445    // request runs against this `Reloadable`, immune to a concurrent reload.
446    let rel = state.current();
447
448    let (parts, body) = req.into_parts();
449    // Collect the body for inspection AND keep any trailers (gRPC carries `grpc-status` in
450    // HTTP/2 trailers); they are relayed to the backend, never inspected.
451    let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
452
453    let mut ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
454
455    // Connection-phase modules (rate limiting) run BEFORE normalization, so
456    // flood traffic is rejected without paying for Fase 2 parsing.
457    let connection_verdict = rel.pipeline.run_connection(&mut ctx);
458    if let Some(denied) = deny_response(&ctx, connection_verdict) {
459        return Ok(denied);
460    }
461
462    // Parser-limit policy (Fase 6 / Pillar 2): on a normalization failure
463    // (limits exceeded / malformed input) `fail_closed` → 400; `fail_open` →
464    // forward UNINSPECTED (logged loudly), trading inspection for availability.
465    let normalized_ok = match rel.normalizer.normalize(&mut ctx) {
466        Ok(()) => true,
467        Err(e) => match rel.resilience.on_parser_limit {
468            FailMode::FailClosed => {
469                warn!(
470                    request_id = %ctx.request_id,
471                    error = %e,
472                    policy = ?FailMode::FailClosed,
473                    "normalization failed: rejecting (on_parser_limit)"
474                );
475                return Ok((
476                    Response::builder()
477                        .status(400)
478                        .body(full_body("Bad Request"))
479                        .unwrap(),
480                    Outcome::BadRequest,
481                ));
482            }
483            FailMode::FailOpen => {
484                warn!(
485                    request_id = %ctx.request_id,
486                    error = %e,
487                    policy = ?FailMode::FailOpen,
488                    "normalization failed: forwarding UNINSPECTED (on_parser_limit)"
489                );
490                false
491            }
492        },
493    };
494
495    let path_and_query = parts
496        .uri
497        .path_and_query()
498        .map(|pq| pq.as_str())
499        .unwrap_or("/")
500        .to_string();
501
502    info!(
503        request_id = %ctx.request_id,
504        method = %ctx.method,
505        path = %path_and_query,
506        client_ip = %ctx.client_ip,
507        "→ request"
508    );
509
510    // Skip inspection when normalization failed under fail_open (no canonical
511    // data to inspect); the request is forwarded uninspected.
512    if normalized_ok {
513        // Fast-path (Fase 7 / Pillar 3): the prefilter decides whether any content
514        // rule *could* match the canonical surface. If not, `run_inspection_gated`
515        // skips inspection and returns Allow with an identical decision log. Sound
516        // by construction (the scope-aware union is the OR of every active rule);
517        // equivalence is proven on the corpus oracle through this same gate.
518        let inspect = rel.prefilter.is_candidate(&ctx);
519        let inspection_verdict = rel.pipeline.run_inspection_gated(&mut ctx, inspect);
520        if let Some(denied) = deny_response(&ctx, inspection_verdict) {
521            return Ok(denied);
522        }
523    }
524
525    forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
526}
527
528/// The SINGLE forwarding path. Both the inspecting handler (`try_forward`) and the
529/// `#[doc(hidden)]` passthrough seam (`try_passthrough`) call it, so the (c) load-test's
530/// no-WAF leg cannot drift from production forwarding — the §13 duplicate-path risk is
531/// removed at the root, not mitigated. Behaviour is unchanged vs the inlined version
532/// (proven by the `passthrough_*` integration tests, green before and after the extract).
533// Forwarding intrinsically threads many request facets (config snapshot, parts, payload +
534// trailers, peer, context); bundling them into a struct would only move the list, not
535// shorten the data this single forwarding path needs.
536#[allow(clippy::too_many_arguments)]
537async fn forward_to_backend(
538    state: &StaticState,
539    rel: &Reloadable,
540    parts: &hyper::http::request::Parts,
541    path_and_query: &str,
542    body_bytes: Bytes,
543    req_trailers: Option<HeaderMap>,
544    client_addr: SocketAddr,
545    ctx: &RequestContext,
546) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
547    let backend_uri: Uri = format!("{}{}", rel.backend, path_and_query).parse()?;
548    let is_grpc = is_grpc_request(parts);
549
550    let mut builder = Request::builder()
551        .method(parts.method.clone())
552        .uri(backend_uri);
553
554    for (name, value) in &parts.headers {
555        if !HOP_BY_HOP.contains(&name.as_str()) {
556            builder = builder.header(name, value);
557        }
558    }
559    // XFF hop record: append the address THIS proxy actually saw (the peer), not
560    // the resolved client IP — that would corrupt the forwarded chain semantics.
561    builder = builder.header("x-forwarded-for", client_addr.ip().to_string());
562    builder = builder.header("x-request-id", ctx.request_id.as_str());
563    // gRPC requires `TE: trailers` on the request (stripped above as hop-by-hop) so the
564    // backend negotiates trailer delivery — re-add it for gRPC targets only.
565    if is_grpc {
566        builder = builder.header("te", "trailers");
567    }
568
569    // gRPC: relay the request trailers and forward over the dedicated h2c client. Non-gRPC:
570    // a plain `Full` body over the existing h1 client — byte-identical to before.
571    let (client, fwd_body) = if is_grpc {
572        (&state.grpc_client, body_with_trailers(body_bytes, req_trailers))
573    } else {
574        (&state.client, full_body(body_bytes))
575    };
576    let fwd_req = builder.body(fwd_body)?;
577
578    // Upstream round-trip under a hard timeout so a stalled origin cannot pin the
579    // worker. Connection/timeout failures apply on_upstream_error (502/503),
580    // returned here rather than bubbling to the generic 502 in `handle`.
581    let upstream = tokio::time::timeout(rel.resilience.upstream_timeout(), async {
582        let resp = client.request(fwd_req).await?;
583        let (resp_parts, resp_body) = resp.into_parts();
584        // Keep the response trailers (gRPC `grpc-status`/`grpc-message`); they are relayed,
585        // not inspected. A non-gRPC h1 response has none → `None` → a plain body downstream.
586        let (resp_bytes, resp_trailers) = collect_with_trailers(resp_body).await?;
587        Ok::<_, Box<dyn std::error::Error + Send + Sync>>((resp_parts, resp_bytes, resp_trailers))
588    })
589    .await;
590
591    let (resp_parts, resp_bytes, resp_trailers) = match upstream {
592        Ok(Ok(triple)) => triple,
593        Ok(Err(e)) => {
594            return Ok((
595                upstream_error_response(ctx, &rel.resilience, &e.to_string()),
596                Outcome::UpstreamError,
597            ))
598        }
599        Err(_elapsed) => {
600            return Ok((
601                upstream_error_response(ctx, &rel.resilience, "upstream timeout"),
602                Outcome::UpstreamError,
603            ))
604        }
605    };
606
607    info!(
608        request_id = %ctx.request_id,
609        status = %resp_parts.status,
610        score = ctx.score,
611        "← response"
612    );
613
614    Ok((
615        Response::from_parts(resp_parts, body_with_trailers(resp_bytes, resp_trailers)),
616        Outcome::Allowed,
617    ))
618}
619
620/// `#[doc(hidden)]` passthrough seam: build the context and forward, SKIPPING the
621/// connection phase, normalization and inspection. The WAF-overhead delta the (c)
622/// load-test publishes = (inspecting leg) − (this leg) = normalize + detect, measured
623/// against the identical `forward_to_backend`. `build_context` runs in BOTH legs (shared
624/// proxy machinery) so it cancels in the delta. Reached only via `bind_passthrough`; no
625/// `config.toml` field selects it.
626async fn try_passthrough(
627    req: Request<Incoming>,
628    state: &StaticState,
629    client_addr: SocketAddr,
630) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
631    let rel = state.current();
632    let (parts, body) = req.into_parts();
633    let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
634    let ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
635    let path_and_query = parts
636        .uri
637        .path_and_query()
638        .map(|pq| pq.as_str())
639        .unwrap_or("/")
640        .to_string();
641    forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
642}
643
644async fn handle(
645    req: Request<Incoming>,
646    state: Arc<StaticState>,
647    client_addr: SocketAddr,
648) -> Result<Response<HyperBoxBody>, Infallible> {
649    // Dispatch on the (config-unreachable) handler mode. `Inspect` is production; the
650    // `try_forward` decision path is unchanged. `Passthrough` is the bench seam.
651    let start = Instant::now();
652    let result = match state.mode {
653        HandlerMode::Inspect => try_forward(req, &state, client_addr).await,
654        HandlerMode::Passthrough => try_passthrough(req, &state, client_addr).await,
655    };
656    // Single recording point (pure side effect): the inner path classifies the Outcome;
657    // an unexpected error here is the WAF's OWN failure → `internal_error`, distinct from the
658    // structured upstream 502/503 already classified inside `forward_to_backend`.
659    let (resp, outcome) = match result {
660        Ok((resp, outcome)) => (resp, outcome),
661        Err(e) => {
662            error!(error = %e, client_ip = %client_addr.ip(), "forwarding error");
663            let resp = Response::builder()
664                .status(502)
665                .body(full_body("Bad Gateway"))
666                .unwrap();
667            (resp, Outcome::InternalError)
668        }
669    };
670    state.metrics.record(outcome, start.elapsed());
671    Ok(resp)
672}
673
674pub struct Proxy {
675    listener: TcpListener,
676    state: Arc<StaticState>,
677    /// Dedicated `/metrics` listener (`Some` ⇒ `[metrics].enabled`). Bound at `bind` for
678    /// fail-fast; the server task is spawned by `run`. NEVER the data port (serving internal
679    /// posture there would be an info leak and would be inspected by the WAF itself).
680    metrics_listener: Option<TcpListener>,
681}
682
683/// Build the enabled built-in modules from config. The rate limiter is given the
684/// SHARED bucket store so its throttle state survives a reload.
685fn build_modules(config: &Config, rl_state: &RateLimitState) -> Vec<Box<dyn WafModule>> {
686    let mut modules: Vec<Box<dyn WafModule>> = vec![Box::new(NoopLogger)];
687    // Framing validation runs first among Connection-phase modules: illegal
688    // framing is refused before it is even counted against the rate limit.
689    if config.modules.request_smuggling.enabled {
690        modules.push(Box::new(RequestSmugglingModule::new()));
691    }
692    if config.rate_limit.enabled {
693        modules.push(Box::new(RateLimitModule::with_state(rl_state.clone())));
694    }
695    if config.modules.sqli.enabled {
696        modules.push(Box::new(SqliModule::new()));
697    }
698    if config.modules.xss.enabled {
699        modules.push(Box::new(XssModule::new()));
700    }
701    if config.modules.path_traversal.enabled {
702        modules.push(Box::new(PathTraversalModule::new()));
703    }
704    if config.modules.rce.enabled {
705        modules.push(Box::new(RceModule::new()));
706    }
707    if config.modules.lfi_rfi.enabled {
708        modules.push(Box::new(LfiRfiModule::new()));
709    }
710    if config.modules.ssrf.enabled {
711        modules.push(Box::new(SsrfModule::new()));
712    }
713    if config.modules.ldap.enabled {
714        modules.push(Box::new(LdapModule::new()));
715    }
716    if config.modules.nosql.enabled {
717        modules.push(Box::new(NosqlModule::new()));
718    }
719    if config.modules.mail.enabled {
720        modules.push(Box::new(MailModule::new()));
721    }
722    if config.modules.ssti.enabled {
723        modules.push(Box::new(SstiModule::new()));
724    }
725    if config.modules.scanner.enabled {
726        modules.push(Box::new(ScannerModule::new()));
727    }
728    if config.modules.ssi.enabled {
729        modules.push(Box::new(SsiModule::new()));
730    }
731    if config.modules.xxe.enabled {
732        modules.push(Box::new(XxeModule::new()));
733    }
734    if config.modules.header_injection.enabled {
735        modules.push(Box::new(HeaderInjectionModule::new()));
736    }
737    if config.modules.graphql.enabled {
738        modules.push(Box::new(GraphqlModule::new()));
739    }
740    if config.modules.grpc.enabled {
741        modules.push(Box::new(GrpcModule::new()));
742    }
743    if config.modules.crs.enabled {
744        modules.push(Box::new(load_crs_module(&config.modules.crs.files)));
745    }
746    if config.modules.wasm.enabled {
747        for plugin in &config.modules.wasm.plugins {
748            if let Some(m) = load_wasm_plugin(plugin, &config.modules.wasm) {
749                modules.push(Box::new(m));
750            }
751        }
752    }
753    modules
754}
755
756/// Load one Proxy-Wasm plugin. A plugin whose file is unreadable or whose `.wasm` cannot be
757/// compiled/instantiated is logged loudly and skipped (fail-open at LOAD, like CRS — the
758/// runtime posture is fail-closed per request). The import report is logged so the operator
759/// sees the coverage, and a plugin relying on stubbed (semantic) host calls is flagged
760/// **DEGRADED** but still loaded — the operator decides (policy D3=A, paletto #4).
761fn load_wasm_plugin(
762    plugin: &waf_core::WasmPluginConfig,
763    cfg: &waf_core::WasmConfig,
764) -> Option<WasmModule> {
765    let bytes = match std::fs::read(&plugin.path) {
766        Ok(b) => b,
767        Err(e) => {
768            error!(file = %plugin.path, error = %e, "WASM: cannot read plugin (skipped)");
769            return None;
770        }
771    };
772    let name = plugin_name(&plugin.path);
773    let opts = WasmOptions {
774        pool_size: cfg.pool_size,
775        fuel_per_request: cfg.fuel_per_request,
776        max_memory_bytes: cfg.max_memory_bytes,
777        checkout_timeout: std::time::Duration::from_millis(cfg.checkout_timeout_ms),
778    };
779    let config_bytes = plugin.config.as_deref().unwrap_or("").as_bytes();
780    match WasmModule::from_bytes(&name, &bytes, config_bytes, &opts) {
781        Ok((module, report)) => {
782            // Informational at boot; the loud "degraded" signal is emitted at runtime the
783            // first time the plugin actually invokes a stubbed semantic host call.
784            info!(plugin = %name, "{}", report.summary());
785            Some(module)
786        }
787        Err(e) => {
788            error!(file = %plugin.path, error = %e, "WASM: plugin failed to load (skipped)");
789            None
790        }
791    }
792}
793
794/// Derive a short plugin name from its path (file stem), for log correlation and `rule_id`.
795fn plugin_name(path: &str) -> String {
796    std::path::Path::new(path)
797        .file_stem()
798        .and_then(|s| s.to_str())
799        .unwrap_or("plugin")
800        .to_string()
801}
802
803/// Read the configured CRS `seclang` files (in order), concatenate them and build the
804/// [`CrsModule`]. An unreadable file is logged loudly and skipped — CRS is an additive
805/// detection layer (default off), so a missing import file fails open (consistent with
806/// `resilience.on_config_error` = fail-open) rather than taking down the proxy; the boot
807/// log makes the gap explicit. The loaded/skipped report and the skipped-rule reasons are
808/// logged so the operator sees exactly what coverage they got (policy D3=A).
809fn load_crs_module(files: &[String]) -> CrsModule {
810    let mut combined = String::new();
811    for path in files {
812        match std::fs::read_to_string(path) {
813            Ok(text) => {
814                combined.push_str(&text);
815                combined.push('\n');
816            }
817            Err(e) => error!(file = %path, error = %e, "CRS import: cannot read file (skipped)"),
818        }
819    }
820    let module = CrsModule::from_source(&combined);
821    info!(files = files.len(), "{}", module.report());
822    if !module.skipped().is_empty() {
823        warn!(
824            skipped = module.skipped().len(),
825            "CRS import: some rules fall outside the supported subset (see debug logs for reasons)"
826        );
827        for s in module.skipped() {
828            debug!(id = ?s.id, line = s.line_no, reason = %s.reason, "CRS import: rule skipped");
829        }
830    }
831    module
832}
833
834/// Build the full config-derived state as a unit (rules recompiled, CIDR
835/// re-parsed). Used at startup AND on every reload, so reload gets exactly the
836/// same construction path — no mixed state. `extra` modules are appended after the
837/// built-ins (test seam; they are NOT carried across a reload).
838fn build_reloadable(
839    config: &Config,
840    rl_state: RateLimitState,
841    extra: Vec<Box<dyn WafModule>>,
842) -> Reloadable {
843    let mut modules = build_modules(config, &rl_state);
844    modules.extend(extra);
845    let pipeline = Pipeline::new(config, modules);
846
847    // PL4 is "empty but legal": warn that a paranoia_level above the highest
848    // shipped rule activates no extra rules (forward-compatible).
849    if config.waf.paranoia_level > waf_detection::HIGHEST_RULE_PARANOIA {
850        warn!(
851            paranoia_level = config.waf.paranoia_level,
852            highest_rule_paranoia = waf_detection::HIGHEST_RULE_PARANOIA,
853            "paranoia_level exceeds the highest existing rule paranoia: no additional rules are activated"
854        );
855    }
856    let ip_resolver = ClientIpResolver::from_config(&config.network);
857    if ip_resolver.trusted_count() < config.network.trusted_proxies.len() {
858        warn!(
859            configured = config.network.trusted_proxies.len(),
860            valid = ip_resolver.trusted_count(),
861            "some trusted_proxies CIDR entries were invalid and skipped"
862        );
863    }
864
865    Reloadable {
866        backend: config.proxy.backend.trim_end_matches('/').to_string(),
867        normalizer: Normalizer::new(&config.limits),
868        pipeline,
869        // Same construction point + config snapshot as the pipeline above.
870        prefilter: ContentPrefilter::new(config.waf.paranoia_level),
871        ip_resolver,
872        resilience: config.resilience,
873    }
874}
875
876impl Proxy {
877    /// Bind a proxy from config with the default extension surface (built-in
878    /// modules, in-memory rate-limit store, file-based TLS cert). For embedding —
879    /// injecting a custom store or extra modules — use [`Proxy::builder`].
880    pub async fn bind(config: &Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
881        Self::builder(config).build().await
882    }
883
884    /// Start configuring a proxy with injectable extension points (the stable
885    /// embedding API): extra detection modules and the rate-limit [`StateStore`].
886    /// Every seam has a default, so `Proxy::builder(cfg).build()` equals
887    /// [`Proxy::bind`].
888    pub fn builder(config: &Config) -> ProxyBuilder<'_> {
889        ProxyBuilder {
890            config,
891            modules: Vec::new(),
892            state_store: None,
893            cert_source: None,
894            module_factory: None,
895            mode: HandlerMode::Inspect,
896        }
897    }
898
899    /// Bind with extra detection modules appended after the built-in set.
900    ///
901    /// Internal seam kept for integration tests (inject a panicking module to verify
902    /// Pillar-2 isolation). The stable public equivalent is
903    /// `Proxy::builder(cfg).modules(..).build()`.
904    #[doc(hidden)]
905    pub async fn bind_with_modules(
906        config: &Config,
907        extra: Vec<Box<dyn WafModule>>,
908    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
909        Self::bind_inner(config, extra, HandlerMode::Inspect, None, None, None).await
910    }
911
912    /// `#[doc(hidden)]` bench seam: bind a proxy that FORWARDS WITHOUT inspecting (no
913    /// connection phase, no normalization, no detection) — the no-WAF leg of the Fase 9
914    /// (c) load-test, sharing `forward_to_backend` with the real path. Not a production
915    /// surface: no `config.toml` field selects it, only this constructor does.
916    #[doc(hidden)]
917    pub async fn bind_passthrough(
918        config: &Config,
919    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
920        Self::bind_inner(config, Vec::new(), HandlerMode::Passthrough, None, None, None).await
921    }
922
923    async fn bind_inner(
924        config: &Config,
925        extra: Vec<Box<dyn WafModule>>,
926        mode: HandlerMode,
927        state_store: Option<RateLimitState>,
928        cert_source: Option<Arc<dyn TlsCertSource>>,
929        module_factory: Option<Arc<ModuleFactory>>,
930    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
931        let listener = TcpListener::bind(config.proxy.listen).await?;
932        let listen_addr = listener.local_addr()?;
933        let client: Client<HttpConnector, HyperBoxBody> =
934            Client::builder(TokioExecutor::new()).build(HttpConnector::new());
935        // Dedicated h2c client for gRPC backends (prior-knowledge HTTP/2 over cleartext).
936        let grpc_client: Client<HttpConnector, HyperBoxBody> =
937            Client::builder(TokioExecutor::new()).http2_only(true).build(HttpConnector::new());
938
939        // Build the TLS terminator BEFORE serving: a required cert that cannot be loaded
940        // is a fatal boot error (fail-closed), never a silent downgrade to cleartext. An
941        // injected cert source (e.g. enterprise ACME/mTLS) replaces the default file source.
942        let tls_acceptor = tls::acceptor_from_source(&config.tls, cert_source)
943            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
944        if tls_acceptor.is_some() {
945            info!(listen = %listen_addr, alpn = ?config.tls.alpn, "TLS termination enabled");
946        }
947
948        // The rate-limiter bucket store lives here (process lifetime), shared into
949        // every (re)built pipeline so reloads never reset the throttle. The
950        // tracked-key cap is fixed at boot (the store outlives reloads). An injected
951        // store (e.g. enterprise Redis) replaces the default in-memory one.
952        let rl_state = state_store
953            .unwrap_or_else(|| RateLimitState::in_memory(config.rate_limit.max_tracked_keys));
954
955        // Injected modules (core 0.3): the static `.add_module` extras first, then the factory's
956        // output. The factory is the SINGLE source of reload-surviving modules, so it also runs
957        // at boot here — a boot-time error is fatal (fail-closed), the same posture an embedder
958        // had when it built these modules inline before passing them in.
959        let mut extra_total = extra;
960        if let Some(factory) = &module_factory {
961            extra_total.extend(factory()?);
962        }
963        let reloadable = build_reloadable(config, rl_state.clone(), extra_total);
964
965        // Metrics (B1): a dedicated `/metrics` listener bound here for fail-fast (a busy
966        // port is a boot error, never a silent miss). Loopback by default; NEVER the data
967        // port. Counters live process-wide and survive reloads.
968        let metrics = Arc::new(Metrics::new());
969        let metrics_listener = if config.metrics.enabled {
970            let l = TcpListener::bind(config.metrics.listen).await?;
971            info!(listen = %l.local_addr()?, "metrics endpoint enabled (/metrics)");
972            Some(l)
973        } else {
974            None
975        };
976
977        Ok(Self {
978            listener,
979            state: Arc::new(StaticState {
980                client,
981                grpc_client,
982                listen_addr,
983                rl_state,
984                current: RwLock::new(Arc::new(reloadable)),
985                mode,
986                tls_acceptor,
987                metrics,
988                module_factory,
989            }),
990            metrics_listener,
991        })
992    }
993
994    /// A cheap, cloneable handle to hot-reload this proxy's configuration.
995    /// Obtain it before `run()` (which consumes `self`); the binary wires it to
996    /// SIGHUP, tests call `reload_from` directly.
997    pub fn reloader(&self) -> Reloader {
998        Reloader(Arc::clone(&self.state))
999    }
1000
1001    pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
1002        self.listener.local_addr()
1003    }
1004
1005    /// Address of the metrics endpoint, when `[metrics].enabled` (tests/operability).
1006    pub fn metrics_addr(&self) -> Option<SocketAddr> {
1007        self.metrics_listener.as_ref().and_then(|l| l.local_addr().ok())
1008    }
1009
1010    pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1011        // Spawn the metrics server (B1) on its dedicated listener, if enabled. It shares the
1012        // process-wide `Metrics` with the datapath and is wholly separate from data serving.
1013        if let Some(metrics_listener) = self.metrics_listener {
1014            let metrics = Arc::clone(&self.state.metrics);
1015            tokio::spawn(serve_metrics(metrics_listener, metrics));
1016        }
1017        loop {
1018            let (stream, client_addr) = self.listener.accept().await?;
1019            let state = Arc::clone(&self.state);
1020
1021            tokio::spawn(async move {
1022                // When TLS is enabled, complete the handshake first; a handshake error is
1023                // logged and the connection dropped (non-fatal — the listener stays up).
1024                // Then serve h1/h2 (TLS by ALPN, cleartext by preface) via the auto Builder.
1025                // The acceptor is Arc-backed → cheap clone, frees `state` to move into serve.
1026                match state.tls_acceptor.clone() {
1027                    Some(acceptor) => match acceptor.accept(stream).await {
1028                        Ok(tls_stream) => {
1029                            serve_connection(TokioIo::new(tls_stream), state, client_addr).await;
1030                        }
1031                        Err(e) => {
1032                            warn!(error = %e, client_ip = %client_addr.ip(), "TLS handshake error");
1033                        }
1034                    },
1035                    None => {
1036                        serve_connection(TokioIo::new(stream), state, client_addr).await;
1037                    }
1038                }
1039            });
1040        }
1041    }
1042}
1043
1044/// Stable builder for embedding the proxy with custom extension points. Obtain it
1045/// via [`Proxy::builder`]. Every seam defaults to the built-in behaviour, so a
1046/// builder with no overrides is identical to [`Proxy::bind`]. The enterprise plugs
1047/// a distributed rate-limit store or premium modules here **without forking**
1048/// (BOUNDARY §4).
1049pub struct ProxyBuilder<'a> {
1050    config: &'a Config,
1051    modules: Vec<Box<dyn WafModule>>,
1052    state_store: Option<RateLimitState>,
1053    cert_source: Option<Arc<dyn TlsCertSource>>,
1054    module_factory: Option<Arc<ModuleFactory>>,
1055    mode: HandlerMode,
1056}
1057
1058impl<'a> ProxyBuilder<'a> {
1059    /// Replace the extra detection modules appended after the built-in set. These
1060    /// run after the built-ins and are NOT carried across a config reload.
1061    pub fn modules(mut self, modules: Vec<Box<dyn WafModule>>) -> Self {
1062        self.modules = modules;
1063        self
1064    }
1065
1066    /// Append a single extra detection module (additive over [`Self::modules`]).
1067    pub fn add_module(mut self, module: Box<dyn WafModule>) -> Self {
1068        self.modules.push(module);
1069        self
1070    }
1071
1072    /// Inject the rate-limit [`StateStore`] (e.g. a distributed Redis store). The
1073    /// store survives config reloads. Defaults to the in-memory token bucket sized
1074    /// from `[rate_limit].max_tracked_keys`.
1075    pub fn state_store(mut self, store: Arc<dyn StateStore>) -> Self {
1076        self.state_store = Some(RateLimitState::with_store(store));
1077        self
1078    }
1079
1080    /// Inject the [`TlsCertSource`] (e.g. enterprise ACME/managed-PKI/mTLS). `[tls].enabled`
1081    /// and `[tls].alpn` still come from config; the source only governs cert provenance, so
1082    /// the config `cert_path`/`key_path` are ignored when one is injected. Defaults to the
1083    /// OPEN `FileCertSource` reading those paths.
1084    pub fn cert_source(mut self, source: Arc<dyn TlsCertSource>) -> Self {
1085        self.cert_source = Some(source);
1086        self
1087    }
1088
1089    /// Inject a [`ModuleFactory`] that (re)builds the extra detection modules (core 0.3).
1090    /// Unlike [`Self::add_module`]/[`Self::modules`] (built once, dropped on a reload), the
1091    /// factory is re-run on every config reload, so injected modules SURVIVE a SIGHUP and are
1092    /// re-`init`'d. It runs at bind too (the single source of reload-surviving modules): a
1093    /// boot error is fatal, and a reload error aborts that reload and keeps the last-good
1094    /// modules. This is the seam an embedder uses to keep premium modules across reloads
1095    /// (BOUNDARY §4). Factory output is appended AFTER any static `.add_module` extras.
1096    pub fn module_factory<F>(mut self, factory: F) -> Self
1097    where
1098        F: Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
1099            + Send
1100            + Sync
1101            + 'static,
1102    {
1103        self.module_factory = Some(Arc::new(factory));
1104        self
1105    }
1106
1107    /// Bind the listener and construct the proxy with the chosen seams.
1108    pub async fn build(self) -> Result<Proxy, Box<dyn std::error::Error + Send + Sync>> {
1109        Proxy::bind_inner(
1110            self.config,
1111            self.modules,
1112            self.mode,
1113            self.state_store,
1114            self.cert_source,
1115            self.module_factory,
1116        )
1117        .await
1118    }
1119}
1120
1121/// Serve one connection with the auto (h1/h2) builder. Generic over the transport so the
1122/// SAME service runs over a plain `TcpStream` or a `TlsStream` — the protocol negotiation
1123/// (h1 vs h2/h2c) is entirely inside `auto::Builder`, and `handle()` stays protocol-neutral.
1124async fn serve_connection<I>(io: I, state: Arc<StaticState>, client_addr: SocketAddr)
1125where
1126    I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
1127{
1128    let svc = service_fn(move |req| {
1129        let state = Arc::clone(&state);
1130        handle(req, state, client_addr)
1131    });
1132    if let Err(e) = auto::Builder::new(TokioExecutor::new())
1133        .serve_connection(io, svc)
1134        .await
1135    {
1136        warn!(error = %e, client_ip = %client_addr.ip(), "connection error");
1137    }
1138}
1139
1140/// Serve the `/metrics` endpoint on its dedicated listener (B1). Plain h1; a scraper opens a
1141/// short connection, GETs `/metrics`, reads the text. Anything that is not `GET /metrics`
1142/// gets a 404 — no path reflection, no other surface.
1143async fn serve_metrics(listener: TcpListener, metrics: Arc<Metrics>) {
1144    loop {
1145        let Ok((stream, _)) = listener.accept().await else { continue };
1146        let metrics = Arc::clone(&metrics);
1147        tokio::spawn(async move {
1148            let svc = service_fn(move |req: Request<Incoming>| {
1149                let metrics = Arc::clone(&metrics);
1150                async move { Ok::<_, Infallible>(metrics_response(&req, &metrics)) }
1151            });
1152            let _ = hyper::server::conn::http1::Builder::new()
1153                .serve_connection(TokioIo::new(stream), svc)
1154                .await;
1155        });
1156    }
1157}
1158
1159/// `GET /metrics` → Prometheus text exposition; anything else → 404.
1160fn metrics_response(req: &Request<Incoming>, metrics: &Metrics) -> Response<HyperBoxBody> {
1161    if req.method() == hyper::Method::GET && req.uri().path() == "/metrics" {
1162        Response::builder()
1163            .status(200)
1164            .header("content-type", "text/plain; version=0.0.4; charset=utf-8")
1165            .body(full_body(metrics.render()))
1166            .unwrap()
1167    } else {
1168        Response::builder().status(404).body(full_body("Not Found")).unwrap()
1169    }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use waf_core::WafMode;
1176
1177    #[test]
1178    fn hop_by_hop_includes_connection_and_host() {
1179        assert!(HOP_BY_HOP.contains(&"connection"));
1180        assert!(HOP_BY_HOP.contains(&"host"));
1181        assert!(HOP_BY_HOP.contains(&"transfer-encoding"));
1182    }
1183
1184    #[test]
1185    fn hop_by_hop_excludes_regular_headers() {
1186        assert!(!HOP_BY_HOP.contains(&"content-type"));
1187        assert!(!HOP_BY_HOP.contains(&"authorization"));
1188        assert!(!HOP_BY_HOP.contains(&"x-custom-header"));
1189    }
1190
1191    #[test]
1192    fn config_parses_from_toml() {
1193        let raw = r#"
1194[proxy]
1195listen = "127.0.0.1:8080"
1196backend = "http://localhost:3000"
1197
1198[waf]
1199mode = "detection-only"
1200block_threshold = 10
1201"#;
1202        let config: Config = toml::from_str(raw).unwrap();
1203        assert_eq!(config.proxy.backend, "http://localhost:3000");
1204        assert_eq!(config.waf.mode, WafMode::DetectionOnly);
1205        assert_eq!(config.waf.block_threshold, 10);
1206    }
1207
1208    #[test]
1209    fn config_uses_default_block_threshold_when_omitted() {
1210        let raw = r#"
1211[proxy]
1212listen = "127.0.0.1:8080"
1213backend = "http://localhost:3000"
1214
1215[waf]
1216mode = "detection-only"
1217"#;
1218        let config: Config = toml::from_str(raw).unwrap();
1219        assert_eq!(config.waf.block_threshold, 5);
1220    }
1221
1222    #[test]
1223    fn config_parses_network_section() {
1224        let raw = r#"
1225[proxy]
1226listen = "127.0.0.1:8080"
1227backend = "http://localhost:3000"
1228
1229[waf]
1230mode = "blocking"
1231
1232[network]
1233trusted_proxies = ["10.0.0.0/8", "::1"]
1234client_ip_header = "X-Forwarded-For"
1235trusted_hops = 2
1236"#;
1237        let config: Config = toml::from_str(raw).unwrap();
1238        assert_eq!(config.network.trusted_proxies, vec!["10.0.0.0/8", "::1"]);
1239        assert_eq!(config.network.client_ip_header, "X-Forwarded-For");
1240        assert_eq!(config.network.trusted_hops, 2);
1241    }
1242
1243    #[test]
1244    fn config_network_defaults_to_failsafe_when_absent() {
1245        let raw = r#"
1246[proxy]
1247listen = "127.0.0.1:8080"
1248backend = "http://localhost:3000"
1249
1250[waf]
1251mode = "detection-only"
1252"#;
1253        let config: Config = toml::from_str(raw).unwrap();
1254        assert!(config.network.trusted_proxies.is_empty());
1255        assert_eq!(config.network.trusted_hops, 1);
1256        assert_eq!(config.network.client_ip_header, "x-forwarded-for".to_string());
1257    }
1258
1259    #[test]
1260    fn config_rejects_unknown_mode() {
1261        let raw = r#"
1262[proxy]
1263listen = "127.0.0.1:8080"
1264backend = "http://localhost:3000"
1265
1266[waf]
1267mode = "unknown-mode"
1268"#;
1269        assert!(toml::from_str::<Config>(raw).is_err());
1270    }
1271
1272    #[test]
1273    fn parse_cookies_splits_on_semicolon() {
1274        let headers = vec![("cookie".to_string(), "session=abc; user=123".to_string())];
1275        let cookies = parse_cookies(&headers);
1276        assert_eq!(cookies.len(), 2);
1277        assert!(cookies.contains(&("session".to_string(), "abc".to_string())));
1278        assert!(cookies.contains(&("user".to_string(), "123".to_string())));
1279    }
1280
1281    #[test]
1282    fn parse_cookies_handles_missing_value() {
1283        let headers = vec![("cookie".to_string(), "flag=; token=xyz".to_string())];
1284        let cookies = parse_cookies(&headers);
1285        assert!(cookies.contains(&("flag".to_string(), "".to_string())));
1286        assert!(cookies.contains(&("token".to_string(), "xyz".to_string())));
1287    }
1288
1289    #[test]
1290    fn parse_cookies_handles_empty_header_list() {
1291        assert!(parse_cookies(&[]).is_empty());
1292    }
1293
1294    #[test]
1295    fn request_id_is_unique_per_call() {
1296        let id1 = next_request_id();
1297        let id2 = next_request_id();
1298        assert_ne!(id1, id2);
1299        assert!(id1.starts_with("req-"));
1300    }
1301}