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