Skip to main content

edgeguard/
proxy.rs

1//! Request path: header-size limit -> rate limit (per-IP / per-route) -> auth -> per-key
2//! rate limit -> method allowlist -> body-size limit -> WAF input inspection -> forward to
3//! upstream.
4//! Response path: header injection (incl. CSP / CSP-report-only) -> cookie hardening ->
5//! strip leaky headers.
6//!
7//! All policy lives in [`Runtime`], held behind an [`ArcSwap`] so a config hot-reload swaps
8//! it atomically without blocking the request path or dropping in-flight connections. The
9//! upstream client and the metric registry sit *outside* the swap so the connection pool and
10//! counters survive a reload.
11
12use std::future::Future;
13use std::net::{IpAddr, SocketAddr};
14use std::pin::Pin;
15use std::sync::Arc;
16use std::task::{Context, Poll};
17use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use arc_swap::ArcSwap;
20use axum::{
21    body::{Body, Bytes},
22    extract::{ConnectInfo, State},
23    http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode},
24};
25use governor::{clock::DefaultClock, state::keyed::DefaultKeyedStateStore, RateLimiter};
26use http_body_util::{BodyExt, Full, Limited};
27use hyper::body::{Body as HttpBody, Frame, SizeHint};
28use hyper_util::client::legacy::{connect::HttpConnector, Client};
29use hyper_util::rt::TokioIo;
30use tokio::net::TcpStream;
31use tracing::{debug, info, warn};
32
33use crate::auth::{AuthEngine, Challenge, Decision};
34use crate::config::{Config, HeadersCfg};
35use crate::limiter::{Admit, DistributedLimiter};
36use crate::metrics::Metrics;
37use crate::waf::{WafEngine, WafMode};
38
39pub type KeyedLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
40/// Rate limiter keyed by the authenticated principal (per-key limiting).
41pub type StrLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
42pub type UpstreamClient = Client<HttpConnector, Full<Bytes>>;
43
44/// Shared, cheaply-cloned handle the router hands to every request. Only the hot-swappable
45/// [`Runtime`] changes on reload; the client and metrics are stable.
46#[derive(Clone)]
47pub struct AppState {
48    pub client: UpstreamClient,
49    pub metrics: Arc<Metrics>,
50    pub runtime: Arc<ArcSwap<Runtime>>,
51    /// Managed-mode control-plane client (`Some` only when `[control_plane]` is enabled). Used to
52    /// forward CSP reports; policy pull + usage reporting run as background tasks in `main`.
53    pub cp: Option<Arc<crate::cp::CpClient>>,
54    /// Shared quota verdict, updated by the managed-mode quota poller and read by the
55    /// hard-stop gate below. Lives here (not on the hot-swappable [`Runtime`]) so a policy reload
56    /// never resets enforcement. Inert unless `control_plane.enforce_quota` is set.
57    pub quota: Arc<crate::cp::QuotaState>,
58}
59
60/// A per-route rate-limit override: requests whose path starts with `prefix` use `limiter`.
61pub struct RouteLimiter {
62    pub prefix: String,
63    pub limiter: Arc<KeyedLimiter>,
64}
65
66/// All request-handling policy derived from a [`Config`]. Rebuilt from scratch on reload and
67/// swapped in atomically.
68pub struct Runtime {
69    pub cfg: Arc<Config>,
70    /// Default upstream base URL (the single `server.upstream`/`app_port`), used when no
71    /// `[[upstreams]]` prefix matches.
72    pub upstream_base: Arc<String>,
73    /// Per-path-prefix upstream overrides as `(prefix, base)`; the longest matching prefix wins.
74    /// Empty unless `[[upstreams]]` is configured.
75    pub upstream_routes: Vec<(String, Arc<String>)>,
76    pub auth: AuthEngine,
77    /// WAF-lite input screener. Inert (`evaluate` returns `None`) when `waf.mode = "off"`.
78    pub waf: WafEngine,
79    /// Compiled CORS policy; `None` when `cors.enabled = false` (the proxy then skips CORS).
80    pub cors: Option<crate::cors::CorsPolicy>,
81    /// Compiled IP allow/deny policy; `None` when both lists are empty (no IP gating).
82    pub access: Option<crate::access::AccessPolicy>,
83    /// Shared-store (distributed) limiter, `Some` when `ratelimit.store` is `memory`/`redis`.
84    /// When present it replaces the three `governor` limiters below (which are then `None`).
85    pub distributed: Option<DistributedLimiter>,
86    /// Global per-client-IP limiter (`None` when rate limiting is disabled or distributed).
87    pub ip_limiter: Option<Arc<KeyedLimiter>>,
88    /// Per-route limiters (also keyed per IP), checked instead of `ip_limiter` on a match.
89    pub route_limiters: Vec<RouteLimiter>,
90    /// Per-principal limiter (`None` when per-key limiting is disabled or distributed).
91    pub key_limiter: Option<Arc<StrLimiter>>,
92    pub max_body: usize,
93    /// Cap on the buffered upstream response body; `0` means unbounded.
94    pub max_response_body: usize,
95    /// Cap on total request header bytes; `0` means disabled.
96    pub max_header_bytes: usize,
97    /// Max time for the upstream request + body read; `None` disables the timeout.
98    pub upstream_timeout: Option<Duration>,
99    /// Forward `text/event-stream` responses unbuffered (SSE passthrough). See
100    /// [`crate::config::ValidationCfg::stream_passthrough`].
101    pub stream_passthrough: bool,
102    /// Tunnel WebSocket / `Upgrade` connections to the upstream. See
103    /// [`crate::config::ValidationCfg::websocket_passthrough`].
104    pub websocket_passthrough: bool,
105    /// Compiled LLM token-metering runtime (price book + on/off). Inert when `[llm]` is disabled.
106    pub llm: Arc<crate::llm::LlmRuntime>,
107    /// Compiled LLM hard-budget engine (gateway L1). `None` when no `[[llm.budgets]]` are configured.
108    pub budgets: Option<Arc<crate::budget::BudgetEngine>>,
109    /// Compiled BYO-key vault (gateway L2). `None` when no `[[llm.keys]]` are configured; when set,
110    /// every proxied request must present a known virtual key.
111    pub keyvault: Option<Arc<crate::keyvault::KeyVault>>,
112    /// Compiled edge-DLP engine (gateway L3). `None` when `[llm.dlp].mode = "off"`.
113    pub dlp: Option<Arc<crate::dlp::DlpEngine>>,
114    /// Compiled OTLP span emitter (gateway L4). Inert when `[llm.telemetry].enabled = false` or no
115    /// endpoint is set; emits one OpenInference span per metered LLM request, fire-and-forget.
116    pub telemetry: Arc<crate::telemetry::TelemetryRuntime>,
117    /// Compiled outbound alerter (gateway L4). Inert when `[alerts].enabled = false` or no webhook is
118    /// set; fires a Slack-compatible alert when a hard budget crosses its threshold, fire-and-forget.
119    pub alerts: Arc<crate::alert::AlertRuntime>,
120}
121
122impl Runtime {
123    /// The upstream base URL to forward `path` to: the longest matching `[[upstreams]]` prefix,
124    /// or the default [`Runtime::upstream_base`] when none match.
125    pub fn pick_upstream(&self, path: &str) -> &str {
126        self.upstream_routes
127            .iter()
128            .filter(|(prefix, _)| path_prefix_matches(path, prefix))
129            .max_by_key(|(prefix, _)| prefix.len())
130            .map(|(_, base)| base.as_str())
131            .unwrap_or_else(|| self.upstream_base.as_str())
132    }
133}
134
135/// Whether `prefix` matches `path` on a path-segment boundary. `prefix` is a validated upstream
136/// route prefix (always starts with `/`); `path` is the request path-and-query. A plain
137/// `str::starts_with` would route a sibling like `/apiary` to the `/api` upstream, so the match
138/// only succeeds when the prefix is followed by a real boundary: end of path, a `/`, or the query
139/// separator `?`. A trailing slash on the prefix is itself a boundary.
140fn path_prefix_matches(path: &str, prefix: &str) -> bool {
141    if prefix == "/" {
142        return true;
143    }
144    match path.strip_prefix(prefix) {
145        Some(rest) => {
146            rest.is_empty()
147                || prefix.ends_with('/')
148                || rest.starts_with('/')
149                || rest.starts_with('?')
150        }
151        None => false,
152    }
153}
154
155/// Hop-by-hop headers that must not be forwarded (RFC 7230 §6.1).
156const HOP_BY_HOP: &[&str] = &[
157    "connection",
158    "keep-alive",
159    "proxy-authenticate",
160    "proxy-authorization",
161    "te",
162    "trailer",
163    "transfer-encoding",
164    "upgrade",
165];
166
167pub async fn handle(
168    State(state): State<AppState>,
169    ConnectInfo(peer): ConnectInfo<SocketAddr>,
170    req: Request<Body>,
171) -> Response<Body> {
172    // One atomic load pins a consistent policy snapshot for the whole request, even if a reload
173    // swaps in a new Runtime mid-flight — routing, auth, *and* the final CORS decoration below all
174    // see the same one (loading again here could decorate with a policy the request never used).
175    let rt = state.runtime.load_full();
176    // Capture the request Origin before the body is consumed, so we can CORS-decorate *every*
177    // response — including EdgeGuard-generated 401/403/429 — not just proxied successes. Without
178    // this, an allowed browser origin sees a generic CORS failure instead of the real status.
179    let origin = req
180        .headers()
181        .get(header::ORIGIN)
182        .and_then(|v| v.to_str().ok())
183        .map(str::to_owned);
184    // Request-tracing inputs, captured before `handle_inner` consumes the request. Only gathered
185    // when a shipper actually exists: with tracing off this is one Option check, not a trace-id mint
186    // and a pile of header reads on every request.
187    let mut req = req;
188    let span_pre = state
189        .metrics
190        .span_shipper()
191        .is_some()
192        .then(|| ServerSpanPre::capture(&req, &rt, peer));
193    // Hand the context to the forward path so it can rewrite `traceparent` for the upstream. Only
194    // when recording: an unsampled request forwards the client's headers untouched.
195    if let Some(pre) = &span_pre {
196        if pre.sampled {
197            req.extensions_mut().insert(pre.ctx);
198        }
199    }
200
201    let mut resp = handle_inner(&state, &rt, peer, req).await;
202    if let Some(origin) = &origin {
203        if let Some(cors) = &rt.cors {
204            cors.decorate_origin(origin, &mut resp);
205        }
206    }
207
208    // Emit the span AFTER CORS decoration, so what is recorded is the response the client receives.
209    if let Some(pre) = span_pre {
210        // The sampling verdict was fixed from the trace id before any work, so a trace is wholly
211        // recorded or wholly not — a half-recorded trace reads in a backend as a missing service.
212        if pre.sampled {
213            if let (Some(shipper), Some(info)) = (
214                state.metrics.span_shipper(),
215                resp.extensions().get::<FinishInfo>().cloned(),
216            ) {
217                shipper.record(pre.finish(&info));
218            }
219        }
220    }
221    resp
222}
223
224/// Everything a server span needs from the request, taken before the body is consumed.
225///
226/// Sampling is decided here, once, from the trace id — so the decision is fixed before any work and
227/// a trace is wholly recorded or wholly not.
228struct ServerSpanPre {
229    ctx: crate::telemetry::TraceContext,
230    method: String,
231    method_original: Option<String>,
232    url_path: String,
233    url_query: Option<String>,
234    url_scheme: String,
235    client_address: Option<String>,
236    server_address: Option<String>,
237    user_agent: Option<String>,
238    protocol_version: Option<String>,
239    start_unix_nano: u128,
240    sampled: bool,
241}
242
243impl ServerSpanPre {
244    fn capture(req: &Request<Body>, rt: &Runtime, peer: SocketAddr) -> ServerSpanPre {
245        let inbound = req
246            .headers()
247            .get("traceparent")
248            .and_then(|v| v.to_str().ok());
249        // One context for the whole request: an inbound traceparent makes this span a child of the
250        // caller's, otherwise a fresh root trace.
251        let ctx = crate::telemetry::TraceContext::from_traceparent(inbound);
252        let sampled = crate::telemetry::trace_sampled(rt.cfg.tracing.sample_rate, &ctx.trace_id);
253
254        let (method, method_original) =
255            crate::telemetry::ServerSpan::normalize_method(req.method().as_str());
256
257        let (path, query) = match req.uri().path_and_query() {
258            Some(pq) => (pq.path().to_string(), pq.query().map(str::to_owned)),
259            None => (req.uri().path().to_string(), None),
260        };
261        // The query is sanitised with the SAME policy as the access log. A span goes to the same
262        // class of destination, so leaving credentials in it would reintroduce in traces exactly the
263        // leak the access log exists to prevent.
264        let url_query = query.map(|q| {
265            crate::accesslog::sanitize_target(
266                &format!("/?{q}"),
267                rt.cfg.log.query,
268                &rt.cfg.log.redact_params,
269            )
270            .split_once('?')
271            .map(|(_, v)| v.to_string())
272            .unwrap_or_default()
273        });
274
275        ServerSpanPre {
276            ctx,
277            method,
278            method_original,
279            url_path: path,
280            url_query,
281            url_scheme: if rt.cfg.tls.enabled { "https" } else { "http" }.to_string(),
282            client_address: Some(peer.ip().to_string()),
283            server_address: req
284                .headers()
285                .get(header::HOST)
286                .and_then(|v| v.to_str().ok())
287                .map(str::to_owned),
288            user_agent: req
289                .headers()
290                .get(header::USER_AGENT)
291                .and_then(|v| v.to_str().ok())
292                .map(str::to_owned),
293            protocol_version: match req.version() {
294                hyper::Version::HTTP_10 => Some("1.0".into()),
295                hyper::Version::HTTP_11 => Some("1.1".into()),
296                hyper::Version::HTTP_2 => Some("2".into()),
297                hyper::Version::HTTP_3 => Some("3".into()),
298                _ => None,
299            },
300            start_unix_nano: unix_nanos(),
301            sampled,
302        }
303    }
304
305    fn finish(self, info: &FinishInfo) -> crate::telemetry::ServerSpan {
306        crate::telemetry::ServerSpan {
307            ctx: self.ctx,
308            method: self.method,
309            method_original: self.method_original,
310            url_path: self.url_path,
311            url_query: self.url_query,
312            url_scheme: self.url_scheme,
313            status_code: info.status,
314            client_address: self.client_address,
315            server_address: self.server_address,
316            user_agent: self.user_agent,
317            protocol_version: self.protocol_version,
318            outcome: info.outcome.clone(),
319            request_id: info.request_id.clone(),
320            start_unix_nano: self.start_unix_nano,
321            end_unix_nano: self.start_unix_nano + info.elapsed.as_nanos(),
322        }
323    }
324}
325
326/// Wall-clock nanoseconds since the Unix epoch, for span timestamps.
327fn unix_nanos() -> u128 {
328    std::time::SystemTime::now()
329        .duration_since(std::time::UNIX_EPOCH)
330        .map(|d| d.as_nanos())
331        .unwrap_or(0)
332}
333
334async fn handle_inner(
335    state: &AppState,
336    rt: &Runtime,
337    peer: SocketAddr,
338    req: Request<Body>,
339) -> Response<Body> {
340    let started = Instant::now();
341    let m = &state.metrics;
342
343    let method = req.method().clone();
344    // Two forms of the request target, and the distinction is load-bearing.
345    //
346    // `raw_path` is what the request actually asked for: it routes, it picks the upstream, it is
347    // what the WAF inspects, and it is forwarded verbatim. Redacting any of that would break
348    // routing and blind the security pipeline.
349    //
350    // `path` is the form that reaches a log line, with credential-shaped query values removed. The
351    // query string is where password-reset links, OAuth `?code=`, presigned URLs and `?api_key=`
352    // live, and an access log is the most-copied artifact this proxy produces. See `accesslog`.
353    let raw_path = req
354        .uri()
355        .path_and_query()
356        .map(|p| p.as_str().to_string())
357        .unwrap_or_else(|| req.uri().path().to_string());
358    let path =
359        crate::accesslog::sanitize_target(&raw_path, rt.cfg.log.query, &rt.cfg.log.redact_params);
360
361    let ip = client_ip(req.headers(), peer, rt.cfg.server.trust_forwarded_for);
362    // Request id for correlation: reuse a well-formed inbound one, else generate. Echoed on the
363    // response and the access log by `finish`, and forwarded upstream below.
364    let rid = resolve_request_id(req.headers());
365
366    // Reserve the internal namespace: never forward `/__edgeguard/*` upstream. Registered
367    // internal routes are matched before this fallback, so anything reaching here under that
368    // prefix is an unknown internal path — a `404` from EdgeGuard, not a request leaked to the
369    // app. This is also what keeps the ops endpoints (health/ready/metrics) unserved on the
370    // public listener in public/private split mode, rather than proxying them to the upstream.
371    if req.uri().path().starts_with("/__edgeguard/") {
372        return finish(
373            m,
374            &rid,
375            &method,
376            &path,
377            ip,
378            started,
379            "not_found",
380            text(StatusCode::NOT_FOUND, "Not Found"),
381        );
382    }
383
384    // 0) IP access control. A coarse network gate (CIDR allow/deny) evaluated before auth and
385    //    rate limiting, so a denied/non-allowlisted client is dropped with `403` before consuming
386    //    any limiter token or auth work. Keys on the same resolved client IP as rate limiting.
387    if let Some(access) = &rt.access {
388        if !access.allowed(ip) {
389            return finish(
390                m,
391                &rid,
392                &method,
393                &path,
394                ip,
395                started,
396                "ip_denied",
397                text(StatusCode::FORBIDDEN, "Forbidden"),
398            );
399        }
400    }
401
402    // 0.1) Total request-header-size limit.
403    if rt.max_header_bytes > 0 && header_bytes(req.headers()) > rt.max_header_bytes {
404        return finish(
405            m,
406            &rid,
407            &method,
408            &path,
409            ip,
410            started,
411            "header_too_large",
412            text(
413                StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
414                "Request Header Fields Too Large",
415            ),
416        );
417    }
418
419    // 0.5) Quota hard-stop (managed mode, opt-in). When the control plane reports the
420    //      edge over its quota, reject the edge's traffic with `429` and a
421    //      month-scale `Retry-After`, until the next successful poll clears it. Off unless
422    //      `control_plane.enforce_quota` is set; the `/__edgeguard/*` endpoints are excluded above,
423    //      so health/ready/metrics keep serving even while over quota.
424    if rt.cfg.control_plane.enforce_quota && state.quota.blocked() {
425        let mut resp = text(StatusCode::TOO_MANY_REQUESTS, "Quota Exceeded");
426        let reset = state.quota.reset_epoch();
427        if reset > 0 {
428            let now = std::time::SystemTime::now()
429                .duration_since(std::time::UNIX_EPOCH)
430                .map(|d| d.as_secs() as i64)
431                .unwrap_or(0);
432            let retry_after = reset.saturating_sub(now).max(0);
433            if let Ok(v) = HeaderValue::from_str(&retry_after.to_string()) {
434                resp.headers_mut().insert(header::RETRY_AFTER, v);
435            }
436        }
437        return finish(m, &rid, &method, &path, ip, started, "over_quota", resp);
438    }
439
440    // 1) Rate limit. A matching per-route override replaces the global per-IP limit. A shared
441    //    store (distributed) limiter, when configured, replaces the in-process limiters; on a
442    //    store error it fails closed (`503`) unless `ratelimit.fail_open` is set.
443    if rt.cfg.ratelimit.enabled {
444        if let Some(d) = &rt.distributed {
445            match d.check_ip_route(ip, &raw_path).await {
446                Admit::Allowed => {}
447                Admit::Limited(scope) => {
448                    m.record_ratelimit_hit(scope);
449                    return finish(
450                        m,
451                        &rid,
452                        &method,
453                        &path,
454                        ip,
455                        started,
456                        "rate_limited",
457                        text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
458                    );
459                }
460                Admit::Error => {
461                    return finish(
462                        m,
463                        &rid,
464                        &method,
465                        &path,
466                        ip,
467                        started,
468                        "limiter_error",
469                        text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
470                    );
471                }
472            }
473        } else {
474            let (limiter, scope) = match longest_route(&rt.route_limiters, &raw_path) {
475                Some(r) => (Some(r.limiter.as_ref()), "route"),
476                None => (rt.ip_limiter.as_deref(), "ip"),
477            };
478            if let Some(limiter) = limiter {
479                if limiter.check_key(&ip).is_err() {
480                    m.record_ratelimit_hit(scope);
481                    return finish(
482                        m,
483                        &rid,
484                        &method,
485                        &path,
486                        ip,
487                        started,
488                        "rate_limited",
489                        text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
490                    );
491                }
492            }
493        }
494    }
495
496    // 1.5) CORS preflight. Answer a browser preflight (`OPTIONS` + `Origin` +
497    //      `Access-Control-Request-Method`) here, *before* auth: a preflight carries no
498    //      credentials, so gating it behind the auth check would make every cross-origin call
499    //      fail. Only a real preflight is short-circuited; a plain `OPTIONS` falls through.
500    if method == Method::OPTIONS {
501        if let Some(cors) = &rt.cors {
502            if let Some(resp) = cors.preflight_response(req.headers()) {
503                return finish(m, &rid, &method, &path, ip, started, "cors_preflight", resp);
504            }
505        }
506    }
507
508    // 2) Authentication. On success we learn the principal for per-key limiting.
509    let principal = match rt.auth.authorize(&rt.cfg.auth, req.headers()).await {
510        Decision::Allow(principal) => principal,
511        Decision::Deny(challenge) => {
512            let mut resp = text(StatusCode::UNAUTHORIZED, "Unauthorized");
513            let challenge_value = match challenge {
514                Challenge::Basic(c) => Some(c),
515                Challenge::Bearer => Some("Bearer".to_string()),
516                Challenge::None => None,
517            };
518            if let Some(c) = challenge_value {
519                if let Ok(v) = HeaderValue::from_str(&c) {
520                    resp.headers_mut().insert(header::WWW_AUTHENTICATE, v);
521                }
522            }
523            return finish(m, &rid, &method, &path, ip, started, "unauthorized", resp);
524        }
525    };
526
527    // 3) Per-key rate limit (only for authenticated principals). Routed to the distributed
528    //    limiter when configured, else the in-process per-key limiter.
529    if let Some(principal) = &principal {
530        let key_admit = if let Some(d) = &rt.distributed {
531            Some(d.check_key(principal).await)
532        } else {
533            rt.key_limiter.as_ref().map(|limiter| {
534                if limiter.check_key(principal).is_err() {
535                    Admit::Limited("key")
536                } else {
537                    Admit::Allowed
538                }
539            })
540        };
541        match key_admit {
542            Some(Admit::Limited(scope)) => {
543                m.record_ratelimit_hit(scope);
544                return finish(
545                    m,
546                    &rid,
547                    &method,
548                    &path,
549                    ip,
550                    started,
551                    "rate_limited",
552                    text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
553                );
554            }
555            Some(Admit::Error) => {
556                return finish(
557                    m,
558                    &rid,
559                    &method,
560                    &path,
561                    ip,
562                    started,
563                    "limiter_error",
564                    text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
565                );
566            }
567            Some(Admit::Allowed) | None => {}
568        }
569    }
570
571    // 4) Method allowlist.
572    let allow = &rt.cfg.validation.allow_methods;
573    if !allow.is_empty()
574        && !allow
575            .iter()
576            .any(|x| x.eq_ignore_ascii_case(method.as_str()))
577    {
578        return finish(
579            m,
580            &rid,
581            &method,
582            &path,
583            ip,
584            started,
585            "method_not_allowed",
586            text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"),
587        );
588    }
589
590    // 4.5) WebSocket / `Upgrade` passthrough (opt-in). An upgrade request can't go through the
591    //      buffer-and-forward path below — it needs a raw bidirectional tunnel. When enabled, hand
592    //      off to `proxy_upgrade`, which forwards the request *with* its upgrade headers (the
593    //      normal path strips them) and splices the connections on a `101`. The request is already
594    //      authenticated and rate-limited at this point. When disabled (default), fall through and
595    //      the upgrade headers are stripped like any other hop-by-hop header.
596    if rt.websocket_passthrough && is_upgrade_request(req.headers()) {
597        // Vault check for upgrade connections: validate the virtual key and swap it for the
598        // provider key before tunnelling. WebSocket frames don't carry a parseable JSON body, so
599        // model egress can't be enforced; any key with a non-empty allowlist is denied
600        // (fail-closed — the tunnel could reach any model on the upstream).
601        let mut req = req;
602        if let Some(vault) = rt.keyvault.as_ref() {
603            let presented = req
604                .headers()
605                .get(header::AUTHORIZATION)
606                .and_then(|v| v.to_str().ok())
607                .and_then(|s| s.strip_prefix("Bearer "))
608                .map(str::trim);
609            match presented.and_then(|k| vault.lookup(k)) {
610                Some(entry) => {
611                    if !entry.model_allowed(None) {
612                        m.record_keyvault("denied_model");
613                        warn!(key = %entry.label(), client_ip = %ip, "WebSocket upgrade denied: key has a model allowlist (model cannot be verified on upgrade connections)");
614                        return finish(
615                            m,
616                            &rid,
617                            &method,
618                            &path,
619                            ip,
620                            started,
621                            "forbidden",
622                            text(StatusCode::FORBIDDEN, "Forbidden"),
623                        );
624                    }
625                    match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
626                        Ok(v) => {
627                            m.record_keyvault("swapped");
628                            req.headers_mut().insert(header::AUTHORIZATION, v);
629                        }
630                        Err(e) => {
631                            warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
632                            return finish(
633                                m,
634                                &rid,
635                                &method,
636                                &path,
637                                ip,
638                                started,
639                                "bad_gateway",
640                                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
641                            );
642                        }
643                    }
644                }
645                None => {
646                    m.record_keyvault("denied_key");
647                    return finish(
648                        m,
649                        &rid,
650                        &method,
651                        &path,
652                        ip,
653                        started,
654                        "unauthorized",
655                        text(StatusCode::UNAUTHORIZED, "Unauthorized"),
656                    );
657                }
658            }
659        }
660        return proxy_upgrade(state, rt, req, &rid, &method, &raw_path, &path, ip, started).await;
661    }
662
663    // 5) Buffer the body up to the configured limit.
664    let (parts, body) = req.into_parts();
665    // Capture an inbound W3C `traceparent` (if any) so an emitted LLM span stitches under the
666    // caller's trace. Cheap header read; only used when `[llm.telemetry]` is enabled.
667    let traceparent = parts
668        .headers
669        .get("traceparent")
670        .and_then(|v| v.to_str().ok())
671        .map(str::to_string);
672    // Team/tag for per-team token/cost metrics (chargeback/showback), from `[llm].team_header`
673    // (default `x-edgeguard-team`; absent → the shared `_none` bucket). Owned so the streamed-path
674    // meter can carry it past the request borrow. Matches the per-team budget scope's keying.
675    let llm_team: Option<String> = parts
676        .headers
677        .get(rt.cfg.llm.team_header.as_str())
678        .and_then(|v| v.to_str().ok())
679        .map(str::trim)
680        .filter(|s| !s.is_empty())
681        .map(str::to_string);
682    let mut body_bytes = match axum::body::to_bytes(body, rt.max_body).await {
683        Ok(b) => b,
684        Err(_) => {
685            return finish(
686                m,
687                &rid,
688                &method,
689                &path,
690                ip,
691                started,
692                "payload_too_large",
693                text(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large"),
694            )
695        }
696    };
697    // Request (ingress) size for managed-mode usage, captured before the body is forwarded upstream.
698    let ingress_bytes = header_bytes(&parts.headers).saturating_add(body_bytes.len());
699
700    // 6) WAF-lite input inspection. A no-op unless `waf.mode` is report/block. The body is
701    //    already buffered above, so inspecting it adds no extra read. On a match: `block` mode
702    //    returns 403; `report` mode logs + counts and forwards. Both record the hit so a
703    //    report-only rollout shows up in `edgeguard_waf_hits_total`.
704    if let Some(hit) = rt.waf.evaluate(&raw_path, &parts.headers, &body_bytes) {
705        m.record_waf_hit(hit.class);
706        match rt.waf.mode() {
707            WafMode::Block => {
708                warn!(
709                    rule = %hit.rule_id,
710                    class = hit.class,
711                    location = hit.location,
712                    client_ip = %ip,
713                    path = %path,
714                    "WAF blocked request"
715                );
716                return finish(
717                    m,
718                    &rid,
719                    &method,
720                    &path,
721                    ip,
722                    started,
723                    "forbidden",
724                    text(StatusCode::FORBIDDEN, "Forbidden"),
725                );
726            }
727            WafMode::Report => warn!(
728                rule = %hit.rule_id,
729                class = hit.class,
730                location = hit.location,
731                client_ip = %ip,
732                path = %path,
733                "WAF rule matched (report-only)"
734            ),
735            // `evaluate` returns `None` when off, so this arm is unreachable; kept for
736            // exhaustiveness.
737            WafMode::Off => {}
738        }
739    }
740
741    // Reversible mask map (gateway L3): populated when inbound redaction runs in reversible mode, so
742    // the response can be unmasked back to the caller's own values (see the response paths below).
743    // Empty unless reversible masking actually replaces a span.
744    let mut mask_map = crate::dlp::MaskMap::default();
745
746    // LLM edge DLP (gateway L3) — inbound prompt. Scan the request body for PII/secrets and apply
747    // the configured mode before forwarding: `block` rejects 403 (the secret never leaves), `redact`
748    // rewrites the forwarded body, `report` logs + counts and passes through unchanged.
749    if let Some(dlp) = rt.dlp.as_ref() {
750        if dlp.scan_request() {
751            let body_text = String::from_utf8_lossy(&body_bytes);
752            let findings = dlp.scan(&body_text);
753            if !findings.is_empty() {
754                for f in &findings {
755                    m.record_dlp_finding(f.category);
756                }
757                match dlp.mode() {
758                    crate::dlp::DlpMode::Block => {
759                        m.record_dlp_blocked();
760                        warn!(findings = findings.len(), client_ip = %ip, "LLM request blocked by DLP (inbound PII/secret)");
761                        return finish(
762                            m,
763                            &rid,
764                            &method,
765                            &path,
766                            ip,
767                            started,
768                            "forbidden",
769                            text(StatusCode::FORBIDDEN, "Forbidden"),
770                        );
771                    }
772                    crate::dlp::DlpMode::Redact => {
773                        // Reversible mode masks to placeholders (recorded in `mask_map`) so the
774                        // response can restore them; plain redact rewrites irreversibly.
775                        let redacted = if dlp.reversible() {
776                            dlp.redact_reversible(&body_text, &findings, &mut mask_map)
777                        } else {
778                            dlp.redact(&body_text, &findings)
779                        };
780                        warn!(
781                            findings = findings.len(),
782                            reversible = dlp.reversible(),
783                            "DLP redacted inbound request"
784                        );
785                        body_bytes = Bytes::from(redacted);
786                    }
787                    crate::dlp::DlpMode::Report => {
788                        warn!(
789                            findings = findings.len(),
790                            "DLP findings in inbound request (report-only)"
791                        )
792                    }
793                    crate::dlp::DlpMode::Off => {}
794                }
795            }
796        }
797    }
798
799    // LLM token metering (gateway L0): if enabled, note the request's `model` *before* the body is
800    // forwarded (it's moved into the upstream request below). `None` for non-JSON / non-LLM bodies,
801    // in which case the request is simply not metered as LLM traffic. Metering is observe-only.
802    // Also parse when the vault is active: the model is needed for egress-allowlist enforcement and
803    // a missing model must be treated as denied for any key that has a non-empty allowlist.
804    let llm_model = if rt.llm.enabled || rt.keyvault.is_some() || rt.budgets.is_some() {
805        crate::llm::parse_request_model(&body_bytes)
806    } else {
807        None
808    };
809
810    // LLM key vault + egress governance (gateway L2): when configured, every proxied request must
811    // present a known virtual key. We resolve it to the mapped provider key (injected upstream
812    // below, so the provider secret never reaches the client) and enforce the key's model egress
813    // allowlist. Runs before the budget reserve so an unknown key / disallowed model never consumes
814    // budget. `upstream_auth`, when set, replaces the outbound `Authorization` header.
815    let mut upstream_auth: Option<HeaderValue> = None;
816    if let Some(vault) = rt.keyvault.as_ref() {
817        let presented = parts
818            .headers
819            .get(header::AUTHORIZATION)
820            .and_then(|v| v.to_str().ok())
821            .and_then(|s| s.strip_prefix("Bearer "))
822            .map(str::trim);
823        match presented.and_then(|k| vault.lookup(k)) {
824            Some(entry) => {
825                // Fail closed: a request whose model is absent or unparseable is denied when the
826                // key has a non-empty allowlist — same as an explicitly off-list model.
827                if !entry.model_allowed(llm_model.as_deref()) {
828                    let model = llm_model.as_deref().unwrap_or("<missing>");
829                    m.record_keyvault("denied_model");
830                    warn!(key = %entry.label(), model = %model, client_ip = %ip, "LLM request denied: model off the key's egress allowlist");
831                    return finish(
832                        m,
833                        &rid,
834                        &method,
835                        &path,
836                        ip,
837                        started,
838                        "forbidden",
839                        text(StatusCode::FORBIDDEN, "Forbidden"),
840                    );
841                }
842                // Convert to a HeaderValue now so a malformed provider key is caught here and
843                // fails with 502 rather than silently leaving the client's virtual key in place.
844                match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
845                    Ok(v) => {
846                        m.record_keyvault("swapped");
847                        upstream_auth = Some(v);
848                    }
849                    Err(e) => {
850                        warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
851                        return finish(
852                            m,
853                            &rid,
854                            &method,
855                            &path,
856                            ip,
857                            started,
858                            "bad_gateway",
859                            text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
860                        );
861                    }
862                }
863            }
864            None => {
865                m.record_keyvault("denied_key");
866                return finish(
867                    m,
868                    &rid,
869                    &method,
870                    &path,
871                    ip,
872                    started,
873                    "unauthorized",
874                    text(StatusCode::UNAUTHORIZED, "Unauthorized"),
875                );
876            }
877        }
878    }
879
880    // LLM unpriced-model policy (gateway L0): when `on_unpriced_model = "block"` and a price book is
881    // configured, a request for a model absent from that book is rejected `402` *before* it reaches
882    // the upstream — an unpriced model is never served at a silent $0. Metering-only deployments (empty `[llm.models]`) never trip this. Runs after the
883    // vault (an unknown key is still `401` first) and before the budget reserve (no budget consumed).
884    if let Some(model) = llm_model.as_ref() {
885        if rt.llm.reject_unpriced(model) {
886            warn!(model = %model, client_ip = %ip, "LLM request denied: model not in price book (on_unpriced_model=block)");
887            return finish(
888                m,
889                &rid,
890                &method,
891                &path,
892                ip,
893                started,
894                "unpriced_model",
895                text(
896                    StatusCode::PAYMENT_REQUIRED,
897                    "Payment Required: model not in price book",
898                ),
899            );
900        }
901    }
902
903    // LLM hard budgets (gateway L1): reserve an estimate against every applicable budget *before*
904    // forwarding, so an over-budget request is denied 429 and never reaches the upstream. The
905    // returned guard reconciles to actual usage on success and auto-releases on any early return
906    // (upstream error / timeout) via its Drop. Only runs when budgets are configured and this is an
907    // LLM request with a known model.
908    let mut budget_guard: Option<ReservationGuard> = None;
909    if let (Some(engine), Some(model)) = (rt.budgets.as_ref(), llm_model.as_ref()) {
910        let est_prompt = crate::llm::estimate_prompt_tokens(body_bytes.len());
911        let est_completion = crate::llm::parse_request_max_tokens(&body_bytes)
912            .unwrap_or(rt.cfg.llm.default_max_tokens);
913        let estimate = crate::budget::Spend {
914            tokens: est_prompt.saturating_add(est_completion),
915            cost_micros: rt
916                .llm
917                .cost_micros(
918                    model,
919                    &crate::llm::Usage {
920                        prompt_tokens: est_prompt,
921                        completion_tokens: est_completion,
922                        ..Default::default()
923                    },
924                )
925                .unwrap_or(0),
926        };
927        // Team/tag for the per-team scope + chargeback, from the configured header (default
928        // `x-edgeguard-team`). Absent → the shared `_none` bucket.
929        let team = parts
930            .headers
931            .get(rt.cfg.llm.team_header.as_str())
932            .and_then(|v| v.to_str().ok())
933            .map(str::trim)
934            .filter(|s| !s.is_empty());
935        let dims = crate::budget::Dims {
936            principal: principal.as_deref(),
937            // Normalize a provider-prefixed model ("openai/gpt-4o") to the bare name for budget
938            // attribution, so a prefixed request can't silently escape a bare-named per-model budget.
939            model: crate::llm::canonical_model(model),
940            team,
941        };
942        match engine.reserve(dims, estimate).await {
943            crate::budget::Reserved::Ok(reservation) => {
944                // Feed the near-limit gauge with each admitted budget's post-reserve consumption,
945                // and fire an alert (edge-triggered, fire-and-forget) when one crosses the threshold.
946                for obs in reservation.observations() {
947                    m.record_budget_consumed(&obs.name, obs.consumed_ratio);
948                    rt.alerts.fire_budget_alert(&obs.name, obs.consumed_ratio);
949                }
950                // Only non-zero on the fail-open path: a store error rolled back an earlier partial
951                // reservation before admitting anyway. Same drift signal as a failed reconcile/release.
952                m.record_budget_reconcile_failures(reservation.rollback_failures());
953                budget_guard = Some(ReservationGuard {
954                    engine: Arc::clone(engine),
955                    reservation: Some(reservation),
956                    metrics: Arc::clone(m),
957                });
958            }
959            crate::budget::Reserved::Denied(denial) => {
960                m.record_budget_blocked(denial.scope.label());
961                m.record_budget_reconcile_failures(denial.rollback_failures);
962                warn!(budget = %denial.name, scope = %denial.scope.label(), model = %model, client_ip = %ip, "LLM request denied: budget exhausted");
963                // A cost cap answers 402 (Payment Required — the spend, not the rate, is the limit);
964                // a token cap answers 429 (Too Many Requests). Both carry the `over_budget` outcome.
965                let (status, body) = match denial.unit {
966                    crate::budget::BudgetUnit::UsdMicros => (
967                        StatusCode::PAYMENT_REQUIRED,
968                        "Payment Required: budget exhausted",
969                    ),
970                    crate::budget::BudgetUnit::Tokens => {
971                        (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
972                    }
973                };
974                return finish(
975                    m,
976                    &rid,
977                    &method,
978                    &path,
979                    ip,
980                    started,
981                    "over_budget",
982                    text(status, body),
983                );
984            }
985            crate::budget::Reserved::Error { rollback_failures } => {
986                m.record_budget_reconcile_failures(rollback_failures);
987                return finish(
988                    m,
989                    &rid,
990                    &method,
991                    &path,
992                    ip,
993                    started,
994                    "limiter_error",
995                    text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
996                );
997            }
998        }
999    }
1000
1001    // 7) Build the upstream request (the per-path upstream override, or the default).
1002    // Two forms again, and for the same reason as `raw_path`/`path` above: `uri` is forwarded and
1003    // must carry the client's query verbatim, `log_uri` is what a timeout or a connection error
1004    // writes to the log. Redacting the access log and then printing the same `?api_key=` in a
1005    // `warn!` on the failure path would leak exactly the credentials this is meant to keep out — and
1006    // on the path an operator is most likely to be reading.
1007    let upstream_base = rt.pick_upstream(&raw_path);
1008    let uri = format!("{upstream_base}{raw_path}");
1009    let log_uri = format!("{upstream_base}{path}");
1010    let mut up = Request::builder().method(parts.method.clone()).uri(&uri);
1011    {
1012        let headers = up.headers_mut().expect("builder headers");
1013        // Drop hop-by-hop headers (the fixed set plus any named by `Connection`) before
1014        // forwarding, so they don't leak across the proxy boundary.
1015        let mut forwarded = parts.headers.clone();
1016        strip_hop_by_hop(&mut forwarded);
1017        // The body is re-sent from a sized `Full`, so the client's Content-Length may be stale (it
1018        // is once DLP redaction rewrote the body). Drop it and let the upstream client recompute the
1019        // correct length from the body, rather than forwarding a mismatched header.
1020        forwarded.remove(header::CONTENT_LENGTH);
1021        for (name, value) in forwarded.iter() {
1022            if name == header::HOST {
1023                continue; // let the client set Host for the upstream
1024            }
1025            headers.insert(name.clone(), value.clone());
1026        }
1027        // Standard forwarding headers.
1028        if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
1029            headers.insert(HeaderName::from_static("x-forwarded-for"), v);
1030        }
1031        headers.insert(
1032            HeaderName::from_static("x-forwarded-proto"),
1033            HeaderValue::from_static(forwarded_proto(&rt.cfg, &parts.headers)),
1034        );
1035        // Forward the (resolved/generated) request id so the upstream logs the same correlation id.
1036        if let Ok(v) = HeaderValue::from_str(&rid) {
1037            headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
1038        }
1039        // W3C trace context, outbound. Without this the app behind the proxy starts its own trace
1040        // and the edge's span is an orphan — the request appears twice in the backend with nothing
1041        // connecting them, which is the failure that makes proxy tracing worth less than none.
1042        //
1043        // The flags are `01` (sampled), because this is only reached when we are recording; that is
1044        // what stops the app's own sampler dropping the other half of the trace.
1045        if let Some(ctx) = parts
1046            .extensions
1047            .get::<crate::telemetry::TraceContext>()
1048            .copied()
1049        {
1050            if let Ok(v) = HeaderValue::from_str(&crate::telemetry::traceparent_header(&ctx)) {
1051                headers.insert(HeaderName::from_static("traceparent"), v);
1052            }
1053            // `tracestate` belongs to the caller's traceparent. Having replaced that, a forwarded
1054            // tracestate refers to a span id no longer in the header and is worse than absent.
1055            headers.remove("tracestate");
1056        }
1057        // L2 key vault: replace the client's `Authorization` (which carried the virtual key) with the
1058        // mapped provider key. The provider secret only ever travels edge→upstream — never back to
1059        // the client — and the client's virtual key never reaches the upstream. The value was
1060        // already validated as a legal HeaderValue when upstream_auth was set above.
1061        if let Some(v) = upstream_auth {
1062            headers.insert(header::AUTHORIZATION, v);
1063        }
1064    }
1065
1066    // Content capture (gateway L4): grab the request body for the emitted span *before* it is
1067    // forwarded and consumed. `body_bytes` is already the DLP-redacted/masked form at this point;
1068    // `capture_for_span` additionally scans+redacts so capture is safe under any DLP mode. Only when
1069    // telemetry + content capture are both on; `None` otherwise (no cost when off).
1070    let telem_input: Option<String> = (rt.telemetry.enabled && rt.telemetry.capture_content)
1071        .then(|| capture_for_span(rt.dlp.as_ref(), &body_bytes, rt.telemetry.max_content_bytes));
1072
1073    let upstream_req = match up.body(Full::new(body_bytes)) {
1074        Ok(r) => r,
1075        Err(e) => {
1076            warn!(error = %e, "failed to build upstream request");
1077            return finish(
1078                m,
1079                &rid,
1080                &method,
1081                &path,
1082                ip,
1083                started,
1084                "bad_gateway",
1085                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1086            );
1087        }
1088    };
1089
1090    // 8) Forward and collect the response under a single deadline, so a stalled upstream
1091    //    can't pin this task. `None` => no timeout (validation.upstream_timeout = "0").
1092    let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
1093    let timed_out = || {
1094        warn!(upstream = %log_uri, "upstream timed out");
1095        text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout")
1096    };
1097
1098    let upstream_resp = match within(deadline, state.client.request(upstream_req)).await {
1099        Ok(Ok(r)) => r,
1100        Ok(Err(e)) => {
1101            warn!(error = %e, upstream = %log_uri, "upstream unreachable");
1102            return finish(
1103                m,
1104                &rid,
1105                &method,
1106                &path,
1107                ip,
1108                started,
1109                "upstream_error",
1110                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1111            );
1112        }
1113        Err(_) => {
1114            return finish(
1115                m,
1116                &rid,
1117                &method,
1118                &path,
1119                ip,
1120                started,
1121                "upstream_timeout",
1122                timed_out(),
1123            )
1124        }
1125    };
1126
1127    let (mut resp_parts, resp_body) = upstream_resp.into_parts();
1128
1129    // 8a) SSE passthrough: forward a `text/event-stream` response frame-by-frame instead of
1130    //     buffering the whole body, so the client sees events as they arrive (time-to-first-byte
1131    //     is preserved). The buffering path below would hold the entire stream until the upstream
1132    //     finished, which defeats SSE. On a streamed body the `max_response_body` cap and the
1133    //     body-read deadline don't apply — the connect/first-byte `upstream_timeout` already
1134    //     bounded time-to-headers — and egress bytes are tallied by `CountingBody` as frames flow.
1135    //     Response hardening is headers-only, so it stays correct on a streaming body.
1136    //
1137    //     Carve-out: when outbound DLP is in `block` mode, streaming can't fail closed — frames would
1138    //     reach the client before the body could be judged, and a stream can't be un-sent. So skip
1139    //     passthrough and fall through to the buffered path (bounded by `max_response_body`), which
1140    //     applies the same block enforcement to `text/event-stream` bodies as to any other response.
1141    //     Block-mode operators trade incremental delivery for the fail-closed contract they configured;
1142    //     `report`/`redact` still stream (redaction rewrites frames inline as they flow).
1143    let dlp_blocks_response = rt
1144        .dlp
1145        .as_ref()
1146        .is_some_and(|d| d.scan_response() && matches!(d.mode(), crate::dlp::DlpMode::Block));
1147    if rt.stream_passthrough && is_event_stream(&resp_parts.headers) && !dlp_blocks_response {
1148        strip_hop_by_hop(&mut resp_parts.headers);
1149        resp_parts.headers.remove(header::CONTENT_LENGTH);
1150        let header_egress = header_bytes(&resp_parts.headers);
1151        // LLM metering on the streamed path: capture the stream tail so the terminal `usage` frame
1152        // can be parsed when the body finishes (see `CountingBody`'s `Drop`). The L1 budget
1153        // reservation rides along — moved out of the guard so the guard's Drop won't release it; the
1154        // body's Drop reconciles it to the streamed usage (or releases on no usage) instead.
1155        let llm_meter = llm_model.as_ref().map(|model| {
1156            let (engine, reservation) = match budget_guard.take() {
1157                Some(mut g) => (Some(g.engine.clone()), g.reservation.take()),
1158                None => (None, None),
1159            };
1160            // Telemetry span context: built only when emission is on (avoids a per-request UUID
1161            // otherwise). Carries an inbound `traceparent` so the gateway span stitches under the app.
1162            let (telemetry, ctx) = if rt.telemetry.enabled {
1163                (
1164                    Some(Arc::clone(&rt.telemetry)),
1165                    crate::telemetry::TraceContext::from_traceparent(traceparent.as_deref()),
1166                )
1167            } else {
1168                (None, crate::telemetry::TraceContext::from_traceparent(None))
1169            };
1170            LlmStreamMeter {
1171                model: model.clone(),
1172                llm: Arc::clone(&rt.llm),
1173                tail: Vec::new(),
1174                engine,
1175                reservation,
1176                started,
1177                first_at: None,
1178                last_at: None,
1179                telemetry,
1180                ctx,
1181                input: telem_input.clone(),
1182                team: llm_team.clone(),
1183                key: principal.clone(),
1184            }
1185        });
1186        // Reversible unmasking (gateway L3): when active, the stream is *unmasked* back to the caller's
1187        // own values from the inbound mask map — the provider only ever saw placeholders. This
1188        // replaces the outbound DLP scan on the streamed path (restore, not re-detect).
1189        let reversible_stream = rt.dlp.as_ref().is_some_and(|d| d.reversible());
1190        // Edge-DLP scan over the streamed response. Counts findings (report); additionally rewrites
1191        // frames when `redact` + `stream_redact` are on (deterministic spans only, NER stays off the
1192        // stream). Built when DLP is on and response scanning is enabled — but not in reversible mode,
1193        // where the unmasker below takes over the stream.
1194        let dlp_scanner = if reversible_stream {
1195            None
1196        } else {
1197            rt.dlp
1198                .as_ref()
1199                .filter(|d| d.scan_response())
1200                .map(|d| DlpStreamScanner {
1201                    engine: Arc::clone(d),
1202                    metrics: Arc::clone(m),
1203                    redact: d.stream_redact(),
1204                    carry: Vec::new(),
1205                })
1206        };
1207        let unmasker = reversible_stream.then(|| UnmaskStreamState {
1208            map: std::mem::take(&mut mask_map),
1209            carry: Vec::new(),
1210        });
1211        let body = Body::new(CountingBody::new(
1212            resp_body,
1213            Arc::clone(m),
1214            ingress_bytes,
1215            header_egress,
1216            llm_meter,
1217            dlp_scanner,
1218            unmasker,
1219        ));
1220        let mut response = Response::from_parts(resp_parts, body);
1221        harden_response(&rt.cfg, &mut response);
1222        // CORS decoration happens centrally in `handle` (covers this and every error path).
1223        return finish(m, &rid, &method, &path, ip, started, "ok", response);
1224    }
1225
1226    // Buffer the upstream body, optionally capped so a huge response can't OOM the proxy.
1227    let mut resp_bytes = if rt.max_response_body > 0 {
1228        match within(
1229            deadline,
1230            Limited::new(resp_body, rt.max_response_body).collect(),
1231        )
1232        .await
1233        {
1234            Ok(Ok(c)) => c.to_bytes(),
1235            Ok(Err(_)) => {
1236                warn!(
1237                    limit = rt.max_response_body,
1238                    "upstream response exceeded max_response_body"
1239                );
1240                return finish(
1241                    m,
1242                    &rid,
1243                    &method,
1244                    &path,
1245                    ip,
1246                    started,
1247                    "upstream_body_too_large",
1248                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1249                );
1250            }
1251            Err(_) => {
1252                return finish(
1253                    m,
1254                    &rid,
1255                    &method,
1256                    &path,
1257                    ip,
1258                    started,
1259                    "upstream_timeout",
1260                    timed_out(),
1261                )
1262            }
1263        }
1264    } else {
1265        match within(deadline, resp_body.collect()).await {
1266            Ok(Ok(c)) => c.to_bytes(),
1267            Ok(Err(e)) => {
1268                warn!(error = %e, "failed reading upstream body");
1269                return finish(
1270                    m,
1271                    &rid,
1272                    &method,
1273                    &path,
1274                    ip,
1275                    started,
1276                    "upstream_body_error",
1277                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1278                );
1279            }
1280            Err(_) => {
1281                return finish(
1282                    m,
1283                    &rid,
1284                    &method,
1285                    &path,
1286                    ip,
1287                    started,
1288                    "upstream_timeout",
1289                    timed_out(),
1290                )
1291            }
1292        }
1293    };
1294
1295    // The body was rebuffered, so let the server recompute framing; strip hop-by-hop headers
1296    // (incl. any named by `Connection`) so they don't leak downstream.
1297    strip_hop_by_hop(&mut resp_parts.headers);
1298    resp_parts.headers.remove(header::CONTENT_LENGTH);
1299
1300    // Managed-mode usage: this is the proxied path, where both bodies are buffered, so the byte
1301    // counts are exact. (`add_usage_request` is recorded for every request in `finish`.)
1302    m.add_usage_bytes(
1303        ingress_bytes,
1304        header_bytes(&resp_parts.headers).saturating_add(resp_bytes.len()),
1305    );
1306
1307    // LLM token metering on the buffered (non-streaming) path: read the upstream's own `usage`
1308    // object. Priced model -> tokens + cost; unmapped model -> tokens only; no usage -> just count
1309    // the request. Best-effort and observe-only — never affects the response.
1310    if let Some(model) = &llm_model {
1311        let usage = if is_event_stream(&resp_parts.headers) {
1312            crate::llm::parse_sse_usage(&resp_bytes)
1313        } else {
1314            crate::llm::parse_response_usage(&resp_bytes)
1315        };
1316        let actual = match usage {
1317            Some(usage) => {
1318                let cost = rt.llm.cost_micros(model, &usage);
1319                let sample = crate::metrics::LlmSample {
1320                    tokens_in: usage.prompt_tokens,
1321                    tokens_out: usage.completion_tokens,
1322                    cached_tokens: usage.cached_tokens,
1323                    reasoning_tokens: usage.reasoning_tokens,
1324                    cost_micros: cost,
1325                };
1326                m.record_llm_usage(model, sample);
1327                m.record_llm_team_usage(llm_team.as_deref().unwrap_or("_none"), &sample);
1328                m.record_llm_key_usage(principal.as_deref().unwrap_or("_anon"), &sample);
1329                // Emit an OpenInference span for this (buffered) request — gateway L4,
1330                // fire-and-forget. No TTFT/TPOT on the non-streaming path. When content capture is on,
1331                // attach the redacted request (captured pre-forward) + redacted response body. At this
1332                // point `resp_bytes` is pre-unmask/pre-outbound-redaction, so `capture_for_span` does
1333                // the redaction so no PII/secret is stored regardless of DLP mode.
1334                if rt.telemetry.enabled {
1335                    let (start_nanos, end_nanos) = wall_clock_span(started);
1336                    let output = rt.telemetry.capture_content.then(|| {
1337                        capture_for_span(
1338                            rt.dlp.as_ref(),
1339                            &resp_bytes,
1340                            rt.telemetry.max_content_bytes,
1341                        )
1342                    });
1343                    rt.telemetry.emit(crate::telemetry::SpanRecord {
1344                        ctx: crate::telemetry::TraceContext::from_traceparent(
1345                            traceparent.as_deref(),
1346                        ),
1347                        name: "llm.chat".into(),
1348                        model: model.clone(),
1349                        provider: None,
1350                        prompt_tokens: usage.prompt_tokens,
1351                        completion_tokens: usage.completion_tokens,
1352                        cached_tokens: usage.cached_tokens,
1353                        reasoning_tokens: usage.reasoning_tokens,
1354                        cost_micros: cost,
1355                        start_unix_nano: start_nanos,
1356                        end_unix_nano: end_nanos,
1357                        ttft: None,
1358                        tpot: None,
1359                        status_ok: resp_parts.status.is_success(),
1360                        input: telem_input.clone(),
1361                        output,
1362                        session_id: None,
1363                    });
1364                }
1365                crate::budget::Spend {
1366                    tokens: usage.total_tokens(),
1367                    cost_micros: cost.unwrap_or(0),
1368                }
1369            }
1370            None => {
1371                m.record_llm_no_usage();
1372                crate::budget::Spend::default()
1373            }
1374        };
1375        // Reconcile the L1 budget reservation to actual spend (releases the over-estimate, or charges
1376        // a low one). `commit` consumes the guard so its Drop won't also release.
1377        if let Some(guard) = budget_guard.take() {
1378            guard.commit(actual).await;
1379        }
1380    }
1381
1382    // Reversible unmasking (gateway L3): when reversible masking is active, the response is *restored*
1383    // to the caller's own values from the inbound mask map — the provider only ever saw placeholders.
1384    // This replaces the outbound scan/redact (the goal is restoration, not re-detection), so it runs
1385    // instead of the block below.
1386    let reversible_active = rt.dlp.as_ref().is_some_and(|d| d.reversible());
1387    if reversible_active {
1388        if !mask_map.is_empty() {
1389            let restored = mask_map.unmask(&String::from_utf8_lossy(&resp_bytes));
1390            resp_bytes = Bytes::from(restored);
1391        }
1392    } else if let Some(dlp) = rt.dlp.as_ref() {
1393        // LLM edge DLP (gateway L3) — outbound completion (buffered path only; the streamed path scans
1394        // frame-by-frame in `CountingBody`). `block` withholds the body, `redact` rewrites it, `report`
1395        // logs + counts. Runs after usage metering so token accounting reads the original `usage`.
1396        if dlp.scan_response() {
1397            let body_text = String::from_utf8_lossy(&resp_bytes);
1398            let findings = dlp.scan(&body_text);
1399            if !findings.is_empty() {
1400                for f in &findings {
1401                    m.record_dlp_finding(f.category);
1402                }
1403                match dlp.mode() {
1404                    crate::dlp::DlpMode::Block => {
1405                        m.record_dlp_blocked();
1406                        warn!(
1407                            findings = findings.len(),
1408                            "DLP withheld response body (outbound PII/secret)"
1409                        );
1410                        resp_parts.status = StatusCode::FORBIDDEN;
1411                        resp_bytes =
1412                            Bytes::from_static(b"{\"error\":\"response withheld by DLP policy\"}");
1413                        resp_parts.headers.remove(header::CONTENT_TYPE);
1414                        resp_parts.headers.insert(
1415                            header::CONTENT_TYPE,
1416                            HeaderValue::from_static("application/json"),
1417                        );
1418                    }
1419                    crate::dlp::DlpMode::Redact => {
1420                        warn!(findings = findings.len(), "DLP redacted response body");
1421                        resp_bytes = Bytes::from(dlp.redact(&body_text, &findings));
1422                    }
1423                    crate::dlp::DlpMode::Report => {
1424                        warn!(
1425                            findings = findings.len(),
1426                            "DLP findings in response (report-only)"
1427                        )
1428                    }
1429                    crate::dlp::DlpMode::Off => {}
1430                }
1431            }
1432        }
1433    }
1434
1435    let mut response = Response::from_parts(resp_parts, Body::from(resp_bytes));
1436    harden_response(&rt.cfg, &mut response);
1437    // CORS decoration happens centrally in `handle` (covers this and every error path).
1438
1439    finish(m, &rid, &method, &path, ip, started, "ok", response)
1440}
1441
1442/// Readiness probe. Returns `200` only if the upstream accepts a TCP connection, so a
1443/// platform's readiness check reflects whether EdgeGuard can actually serve traffic — not
1444/// merely that the process booted. `503` while the upstream is unreachable. (Liveness, i.e.
1445/// "is EdgeGuard itself up", is the separate unconditional `/__edgeguard/health`.)
1446pub async fn ready(State(state): State<AppState>) -> StatusCode {
1447    let rt = state.runtime.load();
1448    let Some((host, port)) = rt.cfg.upstream_probe_addr() else {
1449        return StatusCode::SERVICE_UNAVAILABLE;
1450    };
1451    match tokio::time::timeout(
1452        Duration::from_secs(2),
1453        TcpStream::connect((host.as_str(), port)),
1454    )
1455    .await
1456    {
1457        Ok(Ok(_)) => StatusCode::OK,
1458        _ => StatusCode::SERVICE_UNAVAILABLE,
1459    }
1460}
1461
1462/// Prometheus scrape endpoint (`GET /__edgeguard/metrics`). Like health/ready, it is a
1463/// dedicated route outside the proxy fallback, so it is not subject to auth or rate limits —
1464/// restrict access to `/__edgeguard/*` at the network layer if that matters in your setup.
1465pub async fn metrics_handler(State(state): State<AppState>) -> Response<Body> {
1466    let body = state.metrics.render();
1467    let mut resp = Response::new(Body::from(body));
1468    resp.headers_mut().insert(
1469        header::CONTENT_TYPE,
1470        HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
1471    );
1472    resp
1473}
1474
1475/// CSP violation report sink (`POST /__edgeguard/csp-report`). Browsers POST a JSON report
1476/// here when `headers.csp_report_uri` points at it; we count and log it, then `204`.
1477pub async fn csp_report(State(state): State<AppState>, body: Bytes) -> StatusCode {
1478    state.metrics.record_csp_report();
1479    // Managed mode: forward the raw report to the control plane (fire-and-forget, so the browser's
1480    // 204 is never delayed by an outbound call). Only when a control plane is configured and
1481    // `forward_csp` is on.
1482    if let Some(cp) = &state.cp {
1483        if state.runtime.load().cfg.control_plane.forward_csp {
1484            let cp = cp.clone();
1485            let raw = body.clone();
1486            tokio::spawn(async move { cp.forward_csp(&raw).await });
1487        }
1488    }
1489    // This endpoint is unauthenticated and a report can carry the full document URL,
1490    // referrer, and query strings — logging the whole blob at `info` is both a privacy leak
1491    // and a log-flood vector. Record only the directive that fired, at `debug`.
1492    match serde_json::from_slice::<serde_json::Value>(&body) {
1493        Ok(report) => {
1494            let directive = report
1495                .get("csp-report")
1496                .and_then(|r| {
1497                    r.get("violated-directive")
1498                        .or_else(|| r.get("effective-directive"))
1499                })
1500                .and_then(|v| v.as_str())
1501                .unwrap_or("unknown");
1502            debug!(target: "edgeguard::csp", directive, "CSP violation report");
1503        }
1504        Err(_) => warn!(
1505            bytes = body.len(),
1506            "CSP violation report with an unparseable body"
1507        ),
1508    }
1509    StatusCode::NO_CONTENT
1510}
1511
1512/// Header EdgeGuard reads an inbound request id from and echoes on every response. A
1513/// `&'static str` (rather than a `HeaderName` const, which isn't a const fn) — `HeaderMap`'s
1514/// `get`/`insert` accept it directly.
1515const REQUEST_ID_HEADER: &str = "x-request-id";
1516
1517/// Resolve the request id for log correlation: reuse a well-formed inbound `X-Request-Id` (one a
1518/// CDN/LB already set), else mint a UUID v4. The inbound value is trusted only when it's a short,
1519/// printable-ASCII token, so a hostile client can't inject newlines/control characters into the
1520/// access log or the echoed response header.
1521fn resolve_request_id(headers: &HeaderMap) -> String {
1522    if let Some(v) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
1523        let v = v.trim();
1524        if !v.is_empty() && v.len() <= 128 && v.bytes().all(|b| b.is_ascii_graphic()) {
1525            return v.to_string();
1526        }
1527    }
1528    uuid::Uuid::new_v4().to_string()
1529}
1530
1531/// Resolve the client IP. The peer socket address is authoritative; `X-Forwarded-For`
1532/// (first hop) is honored only when `trust_forwarded` is set, because a directly
1533/// reachable client can otherwise spoof it to forge their identity.
1534fn client_ip(headers: &HeaderMap, peer: SocketAddr, trust_forwarded: bool) -> IpAddr {
1535    if trust_forwarded {
1536        if let Some(xff) = headers.get("x-forwarded-for") {
1537            if let Ok(s) = xff.to_str() {
1538                if let Some(first) = s.split(',').next() {
1539                    if let Ok(ip) = first.trim().parse::<IpAddr>() {
1540                        return ip;
1541                    }
1542                }
1543            }
1544        }
1545    }
1546    peer.ip()
1547}
1548
1549/// Total size of the request headers (sum of name + value bytes), used for the header-size
1550/// policy limit. This is an application-layer approximation of the on-wire header size.
1551fn header_bytes(headers: &HeaderMap) -> usize {
1552    headers
1553        .iter()
1554        .map(|(name, value)| name.as_str().len() + value.as_bytes().len())
1555        .sum()
1556}
1557
1558/// True if the response is a Server-Sent Events stream (`Content-Type: text/event-stream`,
1559/// ignoring any `; charset=…` parameter and leading whitespace). The signal we use to forward a
1560/// response unbuffered when `validation.stream_passthrough` is on.
1561fn is_event_stream(headers: &HeaderMap) -> bool {
1562    headers
1563        .get(header::CONTENT_TYPE)
1564        .and_then(|v| v.to_str().ok())
1565        .map(|v| {
1566            v.split(';')
1567                .next()
1568                .map(str::trim)
1569                .map(|ct| ct.eq_ignore_ascii_case("text/event-stream"))
1570                .unwrap_or(false)
1571        })
1572        .unwrap_or(false)
1573}
1574
1575/// True when the request asks to upgrade the protocol — a `Connection: upgrade` token plus an
1576/// `Upgrade` header (e.g. a WebSocket handshake). The signal for [`proxy_upgrade`].
1577fn is_upgrade_request(headers: &HeaderMap) -> bool {
1578    let conn_has_upgrade = headers
1579        .get_all(header::CONNECTION)
1580        .iter()
1581        .filter_map(|v| v.to_str().ok())
1582        .flat_map(|v| v.split(','))
1583        .any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
1584    conn_has_upgrade && headers.contains_key(header::UPGRADE)
1585}
1586
1587/// Tunnel a WebSocket / `Upgrade` request to the upstream. Unlike the normal path (which strips
1588/// the hop-by-hop `Upgrade`/`Connection` headers), this forwards the handshake intact; on the
1589/// upstream's `101 Switching Protocols` it splices the client and upstream connections into a raw
1590/// bidirectional byte tunnel for the lifetime of the socket. Any other upstream status is passed
1591/// back to the client unchanged, so a rejected handshake surfaces normally.
1592// Mirrors the `handle` forward path's parameters (state/runtime/request + the access-log tuple);
1593// see the note on `finish`.
1594#[allow(clippy::too_many_arguments)]
1595async fn proxy_upgrade(
1596    state: &AppState,
1597    rt: &Runtime,
1598    mut req: Request<Body>,
1599    request_id: &str,
1600    method: &Method,
1601    // `raw_path` routes and is forwarded verbatim; `path` is the redacted form that reaches a log
1602    // line. See the note where they are derived in `handle_inner`.
1603    raw_path: &str,
1604    path: &str,
1605    ip: IpAddr,
1606    started: Instant,
1607) -> Response<Body> {
1608    let m = &state.metrics;
1609
1610    // The client-side upgrade future: once we return a `101`, the server completes it and yields
1611    // the raw client connection. Take it (removing the extension from `req`) before forwarding.
1612    let client_upgrade = hyper::upgrade::on(&mut req);
1613
1614    // Build the upstream request: copy end-to-end headers AND the upgrade/connection headers
1615    // (the handshake needs them), add the forwarding headers, send an empty body.
1616    let upstream_base = rt.pick_upstream(raw_path);
1617    let uri = format!("{upstream_base}{raw_path}");
1618    // The redacted form, for the failure logs below. See the note in `handle_inner`.
1619    let log_uri = format!("{upstream_base}{path}");
1620    let mut up = Request::builder().method(req.method().clone()).uri(&uri);
1621    {
1622        let headers = up.headers_mut().expect("builder headers");
1623        // Strip hop-by-hop headers (the fixed set + any named by `Connection`) before forwarding,
1624        // so a client can't smuggle connection-scoped headers upstream — then re-add the handshake
1625        // headers the upgrade itself needs (`Connection: upgrade` + the requested `Upgrade`).
1626        let upgrade = req.headers().get(header::UPGRADE).cloned();
1627        let mut forwarded = req.headers().clone();
1628        strip_hop_by_hop(&mut forwarded);
1629        for (name, value) in forwarded.iter() {
1630            if name == header::HOST {
1631                continue;
1632            }
1633            headers.insert(name.clone(), value.clone());
1634        }
1635        headers.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
1636        if let Some(v) = upgrade {
1637            headers.insert(header::UPGRADE, v);
1638        }
1639        if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
1640            headers.insert(HeaderName::from_static("x-forwarded-for"), v);
1641        }
1642        headers.insert(
1643            HeaderName::from_static("x-forwarded-proto"),
1644            HeaderValue::from_static(forwarded_proto(&rt.cfg, req.headers())),
1645        );
1646        if let Ok(v) = HeaderValue::from_str(request_id) {
1647            headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
1648        }
1649    }
1650    let upstream_req = match up.body(Full::new(Bytes::new())) {
1651        Ok(r) => r,
1652        Err(e) => {
1653            warn!(error = %e, "failed to build upstream upgrade request");
1654            return finish(
1655                m,
1656                request_id,
1657                method,
1658                path,
1659                ip,
1660                started,
1661                "bad_gateway",
1662                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1663            );
1664        }
1665    };
1666
1667    // Bound the handshake by the same `upstream_timeout` as the buffered path, so a stalled
1668    // upstream can't pin this task (a `None` deadline means no timeout).
1669    let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
1670    let timed_out = || {
1671        warn!(upstream = %log_uri, "upstream timed out (upgrade)");
1672        finish(
1673            m,
1674            request_id,
1675            method,
1676            path,
1677            ip,
1678            started,
1679            "upstream_timeout",
1680            text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout"),
1681        )
1682    };
1683
1684    let mut up_resp = match within(deadline, state.client.request(upstream_req)).await {
1685        Ok(Ok(r)) => r,
1686        Ok(Err(e)) => {
1687            warn!(error = %e, upstream = %log_uri, "upstream unreachable (upgrade)");
1688            return finish(
1689                m,
1690                request_id,
1691                method,
1692                path,
1693                ip,
1694                started,
1695                "upstream_error",
1696                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1697            );
1698        }
1699        Err(_) => return timed_out(),
1700    };
1701
1702    // Upstream declined to upgrade: forward its response as-is (the client sees the rejection),
1703    // but under the same deadline and `max_response_body` cap as the normal buffered path so a
1704    // rejected handshake can't hang or buffer an unbounded body.
1705    if up_resp.status() != StatusCode::SWITCHING_PROTOCOLS {
1706        let (mut parts, body) = up_resp.into_parts();
1707        // Collect the rejection body, capped by `max_response_body` when set. Both arms normalize
1708        // any read/limit error to `()` — the distinction doesn't change the `502` we return.
1709        let body_fut = async {
1710            if rt.max_response_body > 0 {
1711                Limited::new(body, rt.max_response_body)
1712                    .collect()
1713                    .await
1714                    .map(|c| c.to_bytes())
1715                    .map_err(|_| ())
1716            } else {
1717                body.collect().await.map(|c| c.to_bytes()).map_err(|_| ())
1718            }
1719        };
1720        let bytes = match within(deadline, body_fut).await {
1721            Ok(Ok(b)) => b,
1722            Ok(Err(())) => {
1723                warn!("upstream upgrade-rejection body failed or exceeded max_response_body");
1724                return finish(
1725                    m,
1726                    request_id,
1727                    method,
1728                    path,
1729                    ip,
1730                    started,
1731                    "bad_gateway",
1732                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1733                );
1734            }
1735            Err(_) => return timed_out(),
1736        };
1737        strip_hop_by_hop(&mut parts.headers);
1738        parts.headers.remove(header::CONTENT_LENGTH);
1739        let mut response = Response::from_parts(parts, Body::from(bytes));
1740        harden_response(&rt.cfg, &mut response);
1741        return finish(m, request_id, method, path, ip, started, "ok", response);
1742    }
1743
1744    // `101`: wire up the upstream-side upgrade and splice the two connections once both complete.
1745    let upstream_upgrade = hyper::upgrade::on(&mut up_resp);
1746    tokio::spawn(async move {
1747        match tokio::join!(client_upgrade, upstream_upgrade) {
1748            (Ok(client_io), Ok(up_io)) => {
1749                let mut client_io = TokioIo::new(client_io);
1750                let mut up_io = TokioIo::new(up_io);
1751                if let Err(e) = tokio::io::copy_bidirectional(&mut client_io, &mut up_io).await {
1752                    debug!(error = %e, "websocket tunnel closed");
1753                }
1754            }
1755            (c, u) => warn!(
1756                client_ok = c.is_ok(),
1757                upstream_ok = u.is_ok(),
1758                "websocket upgrade did not complete"
1759            ),
1760        }
1761    });
1762
1763    // Return the upstream's `101` — its headers carry `Sec-WebSocket-Accept` etc., and returning a
1764    // `101` is what makes the server upgrade the client side (completing `client_upgrade` above).
1765    // Strip hop-by-hop headers (the fixed set + any named by `Connection`) so the upstream can't
1766    // leak connection-scoped headers downstream, then re-add the handshake headers the upgrade
1767    // itself needs (`Connection: upgrade` + the negotiated `Upgrade`).
1768    let (mut parts, _body) = up_resp.into_parts();
1769    let upgrade = parts.headers.get(header::UPGRADE).cloned();
1770    strip_hop_by_hop(&mut parts.headers);
1771    parts.headers.remove(header::CONTENT_LENGTH);
1772    parts
1773        .headers
1774        .insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
1775    if let Some(v) = upgrade {
1776        parts.headers.insert(header::UPGRADE, v);
1777    }
1778    let response = Response::from_parts(parts, Body::empty());
1779    finish(
1780        m,
1781        request_id,
1782        method,
1783        path,
1784        ip,
1785        started,
1786        "ws_upgrade",
1787        response,
1788    )
1789}
1790
1791/// Wraps a streaming upstream body to tally egress bytes (response headers + each data frame)
1792/// and report them to managed-mode usage when the body is dropped — i.e. after the final frame
1793/// is sent, or earlier if the client disconnects mid-stream (we count what actually went out).
1794/// Used for SSE passthrough: the body isn't buffered, so the exact byte count the buffered path
1795/// takes up front can only be accumulated as frames flow.
1796struct CountingBody<B> {
1797    inner: B,
1798    metrics: Arc<Metrics>,
1799    ingress: usize,
1800    /// Running egress total: response header bytes, then each data frame as it passes.
1801    egress: usize,
1802    /// LLM token metering for a streamed response, when `[llm]` is on and this is an LLM request.
1803    llm: Option<LlmStreamMeter>,
1804    /// Edge-DLP scanner for the streamed response (gateway L3): counts findings, and in
1805    /// `redact` + `stream_redact` mode rewrites the emitted bytes (deterministic spans only).
1806    dlp: Option<DlpStreamScanner>,
1807    /// Reversible unmask over the streamed response (gateway L3): restores placeholders to the
1808    /// caller's own values, carrying a boundary tail so a placeholder split across frames unmasks
1809    /// whole. Present only when reversible masking is active; mutually exclusive with `dlp` redaction.
1810    unmask: Option<UnmaskStreamState>,
1811    /// A non-data (trailers) frame held back so the redaction flush is emitted *before* it, then
1812    /// returned on the next poll. Keeps trailers last even when a buffered redaction tail remains.
1813    pending: Option<Frame<Bytes>>,
1814}
1815
1816/// Streaming reversible-unmask state: the per-request mask map + the held-back boundary tail.
1817struct UnmaskStreamState {
1818    map: crate::dlp::MaskMap,
1819    carry: Vec<u8>,
1820}
1821
1822/// Carry-buffer size for streaming DLP: the last bytes of each frame are kept and prepended to the
1823/// next, so a secret/PII token split across two SSE frames is still detected. Sized above the
1824/// longest signature (private-key header, provider keys).
1825const DLP_STREAM_CARRY: usize = 256;
1826
1827/// Scans a streamed response frame-by-frame for DLP findings, carrying a tail across frame
1828/// boundaries so a split token is still caught. Uses the engine's **deterministic** scan only
1829/// (`scan_stream`): the ML NER family never runs on the stream. In `report` mode it counts findings;
1830/// in `redact` mode (when `[llm.dlp].stream_redact` is on) it rewrites the emitted bytes, holding back
1831/// the boundary tail so a span straddling a frame is redacted whole on the next frame / final flush.
1832struct DlpStreamScanner {
1833    engine: Arc<crate::dlp::DlpEngine>,
1834    metrics: Arc<Metrics>,
1835    /// True when the stream should be rewritten (redact mode + stream_redact), not merely counted.
1836    redact: bool,
1837    /// Report mode: trailing bytes of the previous frame, prepended to the next scan (split-token
1838    /// detection). Redact mode: the un-emitted tail held back so a boundary-straddling span waits.
1839    carry: Vec<u8>,
1840}
1841
1842impl DlpStreamScanner {
1843    /// Report mode: scan one frame (prepended with the carry), counting only findings that touch the
1844    /// new data (so a span already counted from the carry isn't double-counted), then refresh the carry.
1845    fn record_only(&mut self, data: &[u8]) {
1846        let carry_len = self.carry.len();
1847        let mut buf = std::mem::take(&mut self.carry);
1848        buf.extend_from_slice(data);
1849        let text = String::from_utf8_lossy(&buf);
1850        for f in self.engine.scan_stream(&text) {
1851            // Count a finding once, when its span reaches into the newly-arrived bytes.
1852            if f.end > carry_len {
1853                self.metrics.record_dlp_finding(f.category);
1854            }
1855        }
1856        // Keep the last DLP_STREAM_CARRY bytes for the next frame's boundary check.
1857        let keep = buf.len().min(DLP_STREAM_CARRY);
1858        self.carry = buf.split_off(buf.len() - keep);
1859    }
1860
1861    /// Redact mode: append `data` to the held-back carry, redact every deterministic span that ends
1862    /// before the boundary tail, and return the bytes to emit now (the rest waits in `carry`). A span
1863    /// straddling the boundary pulls the emit point back to its start so it is never split.
1864    fn redact_frame(&mut self, data: &[u8]) -> Vec<u8> {
1865        let mut buf = std::mem::take(&mut self.carry);
1866        buf.extend_from_slice(data);
1867        let text = String::from_utf8_lossy(&buf).into_owned();
1868        let findings = self.engine.scan_stream(&text);
1869        // Hold back the last DLP_STREAM_CARRY bytes; never emit past a span that crosses the boundary.
1870        let mut emit_to = text.len().saturating_sub(DLP_STREAM_CARRY);
1871        for f in &findings {
1872            if f.start < emit_to && f.end > emit_to {
1873                emit_to = f.start;
1874            }
1875        }
1876        while emit_to > 0 && !text.is_char_boundary(emit_to) {
1877            emit_to -= 1;
1878        }
1879        let emit: Vec<crate::dlp::Finding> =
1880            findings.into_iter().filter(|f| f.end <= emit_to).collect();
1881        for f in &emit {
1882            self.metrics.record_dlp_finding(f.category);
1883        }
1884        let out = self.engine.redact(&text[..emit_to], &emit).into_bytes();
1885        self.carry = text.as_bytes()[emit_to..].to_vec();
1886        out
1887    }
1888
1889    /// Redact mode: at end-of-stream, redact and return whatever remains in the held-back tail.
1890    fn flush(&mut self) -> Vec<u8> {
1891        if self.carry.is_empty() {
1892            return Vec::new();
1893        }
1894        let buf = std::mem::take(&mut self.carry);
1895        let text = String::from_utf8_lossy(&buf).into_owned();
1896        let findings = self.engine.scan_stream(&text);
1897        for f in &findings {
1898            self.metrics.record_dlp_finding(f.category);
1899        }
1900        self.engine.redact(&text, &findings).into_bytes()
1901    }
1902}
1903
1904/// Cap on the rolling tail buffer kept for SSE token metering. The OpenAI terminal `usage` frame is
1905/// small and arrives just before `[DONE]`, so the last 16 KiB always contains it; bounding the
1906/// buffer keeps streaming memory flat regardless of stream length.
1907const LLM_SSE_TAIL_CAP: usize = 16 * 1024;
1908
1909/// Accumulates the tail of an SSE stream so the terminal `usage` frame can be parsed when the body
1910/// finishes. Holds the model + price book; records to metrics and reconciles the L1 budget on drop.
1911struct LlmStreamMeter {
1912    model: String,
1913    llm: Arc<crate::llm::LlmRuntime>,
1914    tail: Vec<u8>,
1915    /// L1 budget engine + the held reservation, when budgets are configured. Reconciled to the
1916    /// streamed usage on drop (or released on no usage).
1917    engine: Option<Arc<crate::budget::BudgetEngine>>,
1918    reservation: Option<crate::budget::Reservation>,
1919    /// Request-receipt instant, the TTFT clock's zero. TTFT = first streamed frame − `started`.
1920    started: Instant,
1921    /// When the first / most-recent data frame was emitted to the client. TPOT is derived from the
1922    /// span between them and the terminal `usage` output-token count. `None` until the first frame.
1923    first_at: Option<Instant>,
1924    last_at: Option<Instant>,
1925    /// OTLP span emission for the streamed request (gateway L4), when `[llm.telemetry]` is on. On
1926    /// drop, the finalized usage + TTFT/TPOT are emitted as one OpenInference span. `None` when off.
1927    telemetry: Option<Arc<crate::telemetry::TelemetryRuntime>>,
1928    /// The trace context for the emitted span (carries an inbound `traceparent` when present).
1929    ctx: crate::telemetry::TraceContext,
1930    /// Captured (DLP-redacted) request body for the span's `input.value`, when content capture is on.
1931    /// The streamed *output* isn't buffered (SSE is forwarded frame-by-frame), so only input is set.
1932    input: Option<String>,
1933    /// Team/tag for the per-team token/cost metric on drop (absent → `_none`).
1934    team: Option<String>,
1935    /// Authenticated principal for the per-key token/cost metric on drop (absent → `_anon`).
1936    key: Option<String>,
1937}
1938
1939/// Holds an LLM budget reservation for the buffered/non-streaming path. `commit` reconciles it to
1940/// the actual spend; if the guard is dropped without committing (any early return on an upstream
1941/// error / timeout), its `Drop` releases the reservation in full, so a failed request never
1942/// permanently consumes budget.
1943struct ReservationGuard {
1944    engine: Arc<crate::budget::BudgetEngine>,
1945    reservation: Option<crate::budget::Reservation>,
1946    /// For recording reconcile/release failures (counter drift) to Prometheus.
1947    metrics: Arc<Metrics>,
1948}
1949
1950impl ReservationGuard {
1951    /// Reconcile the held reservation to `actual` spend (consuming the guard so `Drop` is a no-op).
1952    async fn commit(mut self, actual: crate::budget::Spend) {
1953        if let Some(reservation) = self.reservation.take() {
1954            let failed = self.engine.reconcile(&reservation, actual).await;
1955            self.metrics.record_budget_reconcile_failures(failed);
1956        }
1957    }
1958}
1959
1960impl Drop for ReservationGuard {
1961    fn drop(&mut self) {
1962        // Not committed (an error path bailed before reconcile): release the whole hold. `release`
1963        // is async, so spawn it onto the current runtime (we're always inside the request task).
1964        if let Some(reservation) = self.reservation.take() {
1965            let engine = Arc::clone(&self.engine);
1966            let metrics = Arc::clone(&self.metrics);
1967            tokio::spawn(async move {
1968                let failed = engine.release(&reservation).await;
1969                metrics.record_budget_reconcile_failures(failed);
1970            });
1971        }
1972    }
1973}
1974
1975impl<B> CountingBody<B> {
1976    fn new(
1977        inner: B,
1978        metrics: Arc<Metrics>,
1979        ingress: usize,
1980        header_egress: usize,
1981        llm: Option<LlmStreamMeter>,
1982        dlp: Option<DlpStreamScanner>,
1983        unmask: Option<UnmaskStreamState>,
1984    ) -> Self {
1985        Self {
1986            inner,
1987            metrics,
1988            ingress,
1989            egress: header_egress,
1990            llm,
1991            dlp,
1992            unmask,
1993            pending: None,
1994        }
1995    }
1996
1997    /// Append the bytes the client will actually receive to the bounded LLM tail buffer (keeping only
1998    /// the last [`LLM_SSE_TAIL_CAP`] bytes), so the terminal `usage` frame is available to parse on drop.
1999    fn push_meter_tail(&mut self, data: &[u8]) {
2000        if let Some(meter) = self.llm.as_mut() {
2001            // Stamp first/last emitted-frame time for TTFT/TPOT (this runs on the bytes the client
2002            // actually receives, so it measures server-side time-to-first-token with no client clock).
2003            if !data.is_empty() {
2004                let now = Instant::now();
2005                meter.first_at.get_or_insert(now);
2006                meter.last_at = Some(now);
2007            }
2008            meter.tail.extend_from_slice(data);
2009            if meter.tail.len() > LLM_SSE_TAIL_CAP {
2010                let drop_n = meter.tail.len() - LLM_SSE_TAIL_CAP;
2011                meter.tail.drain(..drop_n);
2012            }
2013        }
2014    }
2015}
2016
2017impl<B> HttpBody for CountingBody<B>
2018where
2019    B: HttpBody<Data = Bytes> + Unpin,
2020{
2021    type Data = Bytes;
2022    type Error = B::Error;
2023
2024    fn poll_frame(
2025        mut self: Pin<&mut Self>,
2026        cx: &mut Context<'_>,
2027    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
2028        let this = self.as_mut().get_mut();
2029        // A trailers frame held back during a redaction flush is emitted now, before anything else.
2030        if let Some(frame) = this.pending.take() {
2031            return Poll::Ready(Some(Ok(frame)));
2032        }
2033        match Pin::new(&mut this.inner).poll_frame(cx) {
2034            Poll::Ready(Some(Ok(frame))) => {
2035                // Only data frames are scanned/redacted; trailers and the like pass through — but in
2036                // redact mode any buffered tail must be flushed *before* the trailers go out.
2037                let data = match frame.into_data() {
2038                    Ok(data) => data,
2039                    Err(non_data) => {
2040                        if let Some(scanner) = this.dlp.as_mut() {
2041                            if scanner.redact {
2042                                let out = scanner.flush();
2043                                if !out.is_empty() {
2044                                    let out = Bytes::from(out);
2045                                    this.egress = this.egress.saturating_add(out.len());
2046                                    this.push_meter_tail(&out);
2047                                    this.pending = Some(non_data); // emit trailers on the next poll
2048                                    return Poll::Ready(Some(Ok(Frame::data(out))));
2049                                }
2050                            }
2051                        }
2052                        // Reversible unmask: flush the held-back tail before the trailers go out.
2053                        if let Some(u) = this.unmask.as_mut() {
2054                            let out = u.map.flush_unmask(&mut u.carry);
2055                            if !out.is_empty() {
2056                                let out = Bytes::from(out);
2057                                this.egress = this.egress.saturating_add(out.len());
2058                                this.push_meter_tail(&out);
2059                                this.pending = Some(non_data);
2060                                return Poll::Ready(Some(Ok(Frame::data(out))));
2061                            }
2062                        }
2063                        return Poll::Ready(Some(Ok(non_data)));
2064                    }
2065                };
2066                // Reversible unmask (gateway L3): restore placeholders to the caller's own values,
2067                // holding a boundary tail so a placeholder split across frames unmasks whole.
2068                if let Some(u) = this.unmask.as_mut() {
2069                    let out = Bytes::from(u.map.unmask_stream(&mut u.carry, &data));
2070                    this.egress = this.egress.saturating_add(out.len());
2071                    this.push_meter_tail(&out);
2072                    return Poll::Ready(Some(Ok(Frame::data(out))));
2073                }
2074                if this.dlp.as_ref().is_some_and(|s| s.redact) {
2075                    // Redact mode: rewrite the emitted bytes (deterministic spans only). The emitted
2076                    // length may differ from the input frame; account for the bytes the client gets.
2077                    let out = Bytes::from(this.dlp.as_mut().unwrap().redact_frame(&data));
2078                    this.egress = this.egress.saturating_add(out.len());
2079                    this.push_meter_tail(&out);
2080                    Poll::Ready(Some(Ok(Frame::data(out))))
2081                } else {
2082                    this.egress = this.egress.saturating_add(data.len());
2083                    this.push_meter_tail(&data);
2084                    // Report mode (or no redaction): count findings, pass the frame through unchanged.
2085                    if let Some(scanner) = this.dlp.as_mut() {
2086                        scanner.record_only(&data);
2087                    }
2088                    Poll::Ready(Some(Ok(Frame::data(data))))
2089                }
2090            }
2091            Poll::Ready(None) => {
2092                // Upstream ended. In redact mode, flush the held-back tail as one final data frame
2093                // (the next poll sees inner-end again and returns None).
2094                if let Some(scanner) = this.dlp.as_mut() {
2095                    if scanner.redact {
2096                        let out = scanner.flush();
2097                        if !out.is_empty() {
2098                            let out = Bytes::from(out);
2099                            this.egress = this.egress.saturating_add(out.len());
2100                            this.push_meter_tail(&out);
2101                            return Poll::Ready(Some(Ok(Frame::data(out))));
2102                        }
2103                    }
2104                }
2105                // Reversible unmask: flush the held-back tail as one final data frame.
2106                if let Some(u) = this.unmask.as_mut() {
2107                    let out = u.map.flush_unmask(&mut u.carry);
2108                    if !out.is_empty() {
2109                        let out = Bytes::from(out);
2110                        this.egress = this.egress.saturating_add(out.len());
2111                        this.push_meter_tail(&out);
2112                        return Poll::Ready(Some(Ok(Frame::data(out))));
2113                    }
2114                }
2115                Poll::Ready(None)
2116            }
2117            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
2118            Poll::Pending => Poll::Pending,
2119        }
2120    }
2121
2122    fn is_end_stream(&self) -> bool {
2123        // Not done while a held-back trailers frame, or a buffered redaction tail, still has to flow.
2124        if self.pending.is_some() {
2125            return false;
2126        }
2127        if let Some(scanner) = self.dlp.as_ref() {
2128            if scanner.redact && !scanner.carry.is_empty() {
2129                return false;
2130            }
2131        }
2132        if let Some(u) = self.unmask.as_ref() {
2133            if !u.carry.is_empty() {
2134                return false;
2135            }
2136        }
2137        self.inner.is_end_stream()
2138    }
2139
2140    fn size_hint(&self) -> SizeHint {
2141        self.inner.size_hint()
2142    }
2143}
2144
2145impl<B> Drop for CountingBody<B> {
2146    fn drop(&mut self) {
2147        self.metrics.add_usage_bytes(self.ingress, self.egress);
2148        // LLM metering for the streamed body: parse the terminal `usage` frame from the tail. The
2149        // client gets usage only if it sent `stream_options.include_usage`; otherwise `no_usage`.
2150        if let Some(meter) = self.llm.as_mut() {
2151            let actual = match crate::llm::parse_sse_usage(&meter.tail) {
2152                Some(usage) => {
2153                    let cost = meter.llm.cost_micros(&meter.model, &usage);
2154                    let sample = crate::metrics::LlmSample {
2155                        tokens_in: usage.prompt_tokens,
2156                        tokens_out: usage.completion_tokens,
2157                        cached_tokens: usage.cached_tokens,
2158                        reasoning_tokens: usage.reasoning_tokens,
2159                        cost_micros: cost,
2160                    };
2161                    self.metrics.record_llm_usage(&meter.model, sample);
2162                    self.metrics
2163                        .record_llm_team_usage(meter.team.as_deref().unwrap_or("_none"), &sample);
2164                    self.metrics
2165                        .record_llm_key_usage(meter.key.as_deref().unwrap_or("_anon"), &sample);
2166                    // Server-side TTFT/TPOT from the emitted-frame timestamps. TPOT (inter-token
2167                    // latency) is only defined for >1 output token; a single-token response records
2168                    // TTFT alone; an empty stream records neither.
2169                    let (ttft, tpot) = match meter.first_at {
2170                        Some(first) => {
2171                            let ttft = first.saturating_duration_since(meter.started);
2172                            let tpot = match meter.last_at {
2173                                Some(last) if usage.completion_tokens > 1 => {
2174                                    let denom =
2175                                        (usage.completion_tokens - 1).min(u32::MAX as u64) as u32;
2176                                    Some(last.saturating_duration_since(first) / denom)
2177                                }
2178                                _ => None,
2179                            };
2180                            (Some(ttft), tpot)
2181                        }
2182                        None => (None, None),
2183                    };
2184                    if let Some(ttft) = ttft {
2185                        self.metrics.record_llm_latency(ttft, tpot);
2186                    }
2187                    // Emit an OpenInference span for the streamed request (gateway L4, fire-and-forget).
2188                    if let Some(telemetry) = meter.telemetry.as_ref() {
2189                        let (start_nanos, end_nanos) = wall_clock_span(meter.started);
2190                        telemetry.emit(crate::telemetry::SpanRecord {
2191                            ctx: meter.ctx,
2192                            name: "llm.chat".into(),
2193                            model: meter.model.clone(),
2194                            provider: None,
2195                            prompt_tokens: usage.prompt_tokens,
2196                            completion_tokens: usage.completion_tokens,
2197                            cached_tokens: usage.cached_tokens,
2198                            reasoning_tokens: usage.reasoning_tokens,
2199                            cost_micros: cost,
2200                            start_unix_nano: start_nanos,
2201                            end_unix_nano: end_nanos,
2202                            ttft,
2203                            tpot,
2204                            status_ok: true, // a streamed body means the upstream 2xx already began
2205                            input: meter.input.take(),
2206                            output: None, // streamed output isn't buffered (forwarded frame-by-frame)
2207                            session_id: None,
2208                        });
2209                    }
2210                    crate::budget::Spend {
2211                        tokens: usage.total_tokens(),
2212                        cost_micros: cost.unwrap_or(0),
2213                    }
2214                }
2215                None => {
2216                    self.metrics.record_llm_no_usage();
2217                    crate::budget::Spend::default()
2218                }
2219            };
2220            // Reconcile the L1 budget reservation to the streamed actual spend. Async, so spawn it
2221            // (we're inside the request task's runtime when the body is dropped). Record any settle
2222            // failure as counter drift.
2223            if let (Some(engine), Some(reservation)) =
2224                (meter.engine.take(), meter.reservation.take())
2225            {
2226                let metrics = Arc::clone(&self.metrics);
2227                tokio::spawn(async move {
2228                    let failed = engine.reconcile(&reservation, actual).await;
2229                    metrics.record_budget_reconcile_failures(failed);
2230                });
2231            }
2232        }
2233    }
2234}
2235
2236/// Prepare captured LLM content (`input.value` / `output.value`) for a telemetry span: truncate to
2237/// the cap, and when a DLP engine is configured, scan+redact so PII/secrets never leave the box —
2238/// **regardless of the DLP `mode`**, so content capture is safe even under `report`/`block` (which
2239/// don't rewrite the body). Without a DLP engine the content is captured as-is (an explicit
2240/// `capture_content` opt-in). Returns `None` only when capture is off (handled by the caller).
2241fn capture_for_span(dlp: Option<&Arc<crate::dlp::DlpEngine>>, bytes: &[u8], max: usize) -> String {
2242    let text = crate::telemetry::prepare_content(bytes, max);
2243    match dlp {
2244        Some(dlp) => {
2245            let findings = dlp.scan(&text);
2246            if findings.is_empty() {
2247                text
2248            } else {
2249                dlp.redact(&text, &findings)
2250            }
2251        }
2252        None => text,
2253    }
2254}
2255
2256/// Derive wall-clock (unix-nanos) span bounds from a monotonic request-start `Instant`. An `Instant`
2257/// can't be converted to a unix time directly, so we anchor the end at `SystemTime::now()` and
2258/// subtract the measured elapsed duration for the start. Used to timestamp emitted OTLP spans.
2259fn wall_clock_span(started: Instant) -> (u64, u64) {
2260    let end = SystemTime::now()
2261        .duration_since(UNIX_EPOCH)
2262        .unwrap_or_default()
2263        .as_nanos() as u64;
2264    let start = end.saturating_sub(started.elapsed().as_nanos() as u64);
2265    (start, end)
2266}
2267
2268/// Remove hop-by-hop headers so they don't leak across the proxy boundary (RFC 7230 §6.1):
2269/// the fixed [`HOP_BY_HOP`] set plus any header *named* in a `Connection` header. Applied in
2270/// both directions (request to upstream, response to client).
2271fn strip_hop_by_hop(headers: &mut HeaderMap) {
2272    // Header names listed in any `Connection` header are connection-specific; collect them
2273    // before mutating (the borrow of `headers` must end before we remove).
2274    let connection_named: Vec<HeaderName> = headers
2275        .get_all(header::CONNECTION)
2276        .iter()
2277        .filter_map(|v| v.to_str().ok())
2278        .flat_map(|v| v.split(','))
2279        .filter_map(|token| HeaderName::from_bytes(token.trim().as_bytes()).ok())
2280        .collect();
2281    for name in HOP_BY_HOP {
2282        headers.remove(*name);
2283    }
2284    for name in connection_named {
2285        headers.remove(name);
2286    }
2287}
2288
2289/// Decide the `X-Forwarded-Proto` to send upstream. If EdgeGuard terminates TLS, the client
2290/// hop is HTTPS. Otherwise, behind a trusted edge (`trust_forwarded_for`) we preserve the
2291/// proto the edge reported (falling back to `http`); an untrusted client's `X-Forwarded-Proto`
2292/// is never honored, mirroring the client-IP trust model. Returns a `'static` token so the
2293/// caller can build a `HeaderValue` without fallible parsing.
2294fn forwarded_proto(cfg: &Config, headers: &HeaderMap) -> &'static str {
2295    if cfg.tls.enabled {
2296        return "https";
2297    }
2298    if cfg.server.trust_forwarded_for {
2299        if let Some(value) = headers
2300            .get("x-forwarded-proto")
2301            .and_then(|v| v.to_str().ok())
2302        {
2303            match value.split(',').next().map(str::trim) {
2304                Some(p) if p.eq_ignore_ascii_case("https") => return "https",
2305                Some(p) if p.eq_ignore_ascii_case("http") => return "http",
2306                _ => {}
2307            }
2308        }
2309    }
2310    "http"
2311}
2312
2313/// Pick the most specific (longest-prefix) per-route limiter matching `path`, if any.
2314fn longest_route<'a>(routes: &'a [RouteLimiter], path: &str) -> Option<&'a RouteLimiter> {
2315    routes
2316        .iter()
2317        .filter(|r| path.starts_with(&r.prefix))
2318        .max_by_key(|r| r.prefix.len())
2319}
2320
2321/// The HSTS header value EdgeGuard emits when `headers.hsts` is on: a two-year `max-age`
2322/// including subdomains. A named constant so the live proxy and the static-host config
2323/// generator ([`crate::generate`]) can't drift on it.
2324pub const HSTS_VALUE: &str = "max-age=63072000; includeSubDomains";
2325
2326/// The constant security response headers EdgeGuard injects, derived from the `[headers]`
2327/// policy. This is the **single source of truth** shared by the live response-hardening path
2328/// ([`harden_response`]) and the static-host config generator ([`crate::generate`]), so a
2329/// generated `_headers` file / edge-middleware snippet matches exactly what the proxy would add
2330/// at runtime. Returns `(name, value)` pairs with canonically-cased names (for readable
2331/// generated output); the proxy normalizes the case when it inserts them.
2332///
2333/// Cookie hardening and leaky-header *stripping* are deliberately **not** here: both rewrite the
2334/// upstream's actual response (`Set-Cookie`, `Server`/`X-Powered-By`), which a static file that
2335/// can only "always add this header" cannot express. The generator documents that gap; the
2336/// WASM worker, which sees the real response, applies them too.
2337pub fn security_headers(cfg: &HeadersCfg) -> Vec<(&'static str, String)> {
2338    let mut out: Vec<(&'static str, String)> = Vec::with_capacity(6);
2339    out.push(("X-Content-Type-Options", "nosniff".to_string()));
2340    if !cfg.frame_options.is_empty() {
2341        out.push(("X-Frame-Options", cfg.frame_options.clone()));
2342    }
2343    if !cfg.referrer_policy.is_empty() {
2344        out.push(("Referrer-Policy", cfg.referrer_policy.clone()));
2345    }
2346    if !cfg.permissions_policy.is_empty() {
2347        out.push(("Permissions-Policy", cfg.permissions_policy.clone()));
2348    }
2349    if !cfg.csp.is_empty() {
2350        // Append a report-uri directive if configured, and choose enforce vs. report-only.
2351        let mut value = cfg.csp.clone();
2352        if !cfg.csp_report_uri.is_empty() {
2353            value.push_str("; report-uri ");
2354            value.push_str(&cfg.csp_report_uri);
2355        }
2356        let name = if cfg.csp_report_only {
2357            "Content-Security-Policy-Report-Only"
2358        } else {
2359            "Content-Security-Policy"
2360        };
2361        out.push((name, value));
2362    }
2363    if cfg.hsts {
2364        out.push(("Strict-Transport-Security", HSTS_VALUE.to_string()));
2365    }
2366    out
2367}
2368
2369/// Inject security headers, harden Set-Cookie, and strip leaky headers.
2370fn harden_response(cfg: &Config, resp: &mut Response<Body>) {
2371    let h = resp.headers_mut();
2372
2373    // Inject the constant security headers (shared with the static-host generator via
2374    // `security_headers`, so the two never diverge). `from_bytes` normalizes the canonical
2375    // casing to lowercase; these names/values are all valid, so the inserts don't fail.
2376    for (name, value) in security_headers(&cfg.headers) {
2377        if let (Ok(n), Ok(v)) = (
2378            HeaderName::from_bytes(name.as_bytes()),
2379            HeaderValue::from_str(&value),
2380        ) {
2381            h.insert(n, v);
2382        }
2383    }
2384
2385    // Strip leaky headers.
2386    for name in &cfg.headers.strip {
2387        if let Ok(hn) = HeaderName::from_bytes(name.as_bytes()) {
2388            h.remove(hn);
2389        }
2390    }
2391
2392    // Harden cookies: ensure Secure, HttpOnly, and a SameSite default.
2393    if cfg.headers.force_secure_cookies {
2394        let cookies: Vec<HeaderValue> = h.get_all(header::SET_COOKIE).iter().cloned().collect();
2395        if !cookies.is_empty() {
2396            h.remove(header::SET_COOKIE);
2397            for c in cookies {
2398                if let Ok(s) = c.to_str() {
2399                    // HttpOnly is added unless globally disabled or this cookie's name is
2400                    // exempt — the latter keeps a double-submit CSRF cookie JS-readable.
2401                    let add_httponly = cfg.headers.httponly_cookies
2402                        && !cookie_name_exempt(s, &cfg.headers.httponly_cookie_exempt);
2403                    let hardened = harden_cookie(s, add_httponly);
2404                    if let Ok(v) = HeaderValue::from_str(&hardened) {
2405                        h.append(header::SET_COOKIE, v);
2406                    }
2407                } else {
2408                    h.append(header::SET_COOKIE, c);
2409                }
2410            }
2411        }
2412    }
2413}
2414
2415/// The cookie's NAME — the token before the first `=` of the `name=value` pair. Cookies are
2416/// case-sensitive, so this is returned as-is (trimmed) for an exact exemption match.
2417fn cookie_name(cookie: &str) -> &str {
2418    cookie
2419        .split(';')
2420        .next()
2421        .unwrap_or("")
2422        .split('=')
2423        .next()
2424        .unwrap_or("")
2425        .trim()
2426}
2427
2428/// True when this cookie's name is on the `httponly_cookie_exempt` allowlist.
2429fn cookie_name_exempt(cookie: &str, exempt: &[String]) -> bool {
2430    let name = cookie_name(cookie);
2431    exempt.iter().any(|e| e == name)
2432}
2433
2434/// Harden one `Set-Cookie` value: ensure `Secure` and a `SameSite` default, and add
2435/// `HttpOnly` when `add_httponly` is set (the caller clears it for exempt cookies).
2436fn harden_cookie(cookie: &str, add_httponly: bool) -> String {
2437    // Inspect attribute *names* (the tokens after the first `name=value` pair), not the
2438    // whole string — otherwise a value like `session=securetoken` would look like it
2439    // already carries `Secure` and we'd skip hardening it.
2440    let attrs: std::collections::HashSet<String> = cookie
2441        .split(';')
2442        .skip(1)
2443        .filter_map(|p| p.trim().split('=').next())
2444        .map(|k| k.trim().to_ascii_lowercase())
2445        .collect();
2446
2447    let mut out = cookie.trim_end_matches(';').to_string();
2448    if !attrs.contains("secure") {
2449        out.push_str("; Secure");
2450    }
2451    if add_httponly && !attrs.contains("httponly") {
2452        out.push_str("; HttpOnly");
2453    }
2454    if !attrs.contains("samesite") {
2455        out.push_str("; SameSite=Lax");
2456    }
2457    out
2458}
2459
2460/// Run `fut` bounded by an optional deadline. `None` means no timeout. On success returns
2461/// the future's own output; `Err(Elapsed)` if the deadline passed first.
2462async fn within<F: Future>(
2463    deadline: Option<tokio::time::Instant>,
2464    fut: F,
2465) -> Result<F::Output, tokio::time::error::Elapsed> {
2466    match deadline {
2467        Some(dl) => tokio::time::timeout_at(dl, fut).await,
2468        None => Ok(fut.await),
2469    }
2470}
2471
2472fn text(status: StatusCode, msg: &str) -> Response<Body> {
2473    let mut resp = Response::new(Body::from(msg.to_string()));
2474    *resp.status_mut() = status;
2475    resp.headers_mut().insert(
2476        header::CONTENT_TYPE,
2477        HeaderValue::from_static("text/plain; charset=utf-8"),
2478    );
2479    resp
2480}
2481
2482/// What `finish()` decided about a request, carried back on the response.
2483///
2484/// Threading a span handle through `finish` would mean editing all 38 of its call sites, and the one
2485/// that gets missed is always the interesting one. `finish` is the single convergence point every
2486/// terminal path already goes through, so it stamps its verdict into the response extensions and
2487/// `handle()` — which is the only place that has both the request and the finished response —
2488/// builds the span. One producer, one consumer.
2489#[derive(Clone, Debug)]
2490struct FinishInfo {
2491    outcome: String,
2492    status: u16,
2493    elapsed: Duration,
2494    request_id: String,
2495}
2496
2497/// RFC3339 UTC, to the second, without pulling in a date-time crate.
2498///
2499/// A collector needs to order records from many edges, and arrival order will not do it — batching
2500/// means a record can arrive seconds after one from another box that happened later. Second
2501/// resolution is enough for that: the access log already reports latency separately, and sub-second
2502/// ordering within one edge is what `request_id` is for.
2503fn rfc3339_now() -> String {
2504    let secs = std::time::SystemTime::now()
2505        .duration_since(std::time::UNIX_EPOCH)
2506        .map(|d| d.as_secs() as i64)
2507        .unwrap_or(0);
2508    rfc3339(secs)
2509}
2510
2511/// The conversion itself, split out from [`rfc3339_now`] so it is testable against known
2512/// timestamps. A hand-rolled date conversion that is only ever called with "now" is a conversion
2513/// nobody has checked.
2514fn rfc3339(secs: i64) -> String {
2515    // Civil-time conversion from a Unix timestamp (days since epoch -> y/m/d), the standard
2516    // algorithm. Cheaper than a dependency for one format, and it has no local-timezone concept to
2517    // get wrong: this is UTC by construction.
2518    let days = secs.div_euclid(86_400);
2519    let tod = secs.rem_euclid(86_400);
2520    let (h, mi, sec) = (tod / 3600, (tod % 3600) / 60, tod % 60);
2521
2522    let z = days + 719_468;
2523    let era = z.div_euclid(146_097);
2524    let doe = z.rem_euclid(146_097);
2525    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
2526    let y = yoe + era * 400;
2527    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2528    let mp = (5 * doy + 2) / 153;
2529    let d = doy - (153 * mp + 2) / 5 + 1;
2530    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2531    let y = if m <= 2 { y + 1 } else { y };
2532    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
2533}
2534
2535/// Emit a structured access-log line, record metrics, stamp the response with `X-Request-Id`,
2536/// and return it.
2537// All args are part of the access-log/identity tuple for one request; bundling them in a struct
2538// would just move the same fields behind another name at every (already terse) call site.
2539#[allow(clippy::too_many_arguments)]
2540fn finish(
2541    metrics: &Metrics,
2542    request_id: &str,
2543    method: &Method,
2544    path: &str,
2545    ip: IpAddr,
2546    started: Instant,
2547    outcome: &str,
2548    mut resp: Response<Body>,
2549) -> Response<Body> {
2550    // Echo the request id on every response (including error responses) so a client / upstream /
2551    // log can be correlated. `resolve_request_id` guarantees it's a valid header value.
2552    if let Ok(v) = HeaderValue::from_str(request_id) {
2553        resp.headers_mut().insert(REQUEST_ID_HEADER, v);
2554    }
2555    let elapsed = started.elapsed();
2556    info!(
2557        request_id,
2558        %method,
2559        path = %path,
2560        client_ip = %ip,
2561        status = resp.status().as_u16(),
2562        outcome,
2563        latency_ms = elapsed.as_millis() as u64,
2564        "request"
2565    );
2566    metrics.record_request(outcome);
2567    metrics.observe_latency(elapsed);
2568    // For `handle()` to build the server span. `outcome` is a &'static str from a fixed set, so this
2569    // clone is one String (the request id) per request and nothing else.
2570    let status = resp.status().as_u16();
2571    resp.extensions_mut().insert(FinishInfo {
2572        outcome: outcome.to_string(),
2573        status,
2574        elapsed,
2575        request_id: request_id.to_string(),
2576    });
2577    // Ship the same line off-box, when `[log.ship]` is configured. `record` is a single non-blocking
2578    // `try_send` onto a bounded queue — it never awaits and never touches the network, so a slow or
2579    // absent collector costs dropped telemetry rather than request latency. `path` is already
2580    // sanitised by `accesslog::sanitize_target` at the call sites, so credentials in the query
2581    // string do not travel to the collector either.
2582    if let Some(shipper) = metrics.log_shipper() {
2583        shipper.record(crate::logship::AccessRecord {
2584            ts: rfc3339_now(),
2585            request_id: request_id.to_string(),
2586            method: method.to_string(),
2587            target: path.to_string(),
2588            client_ip: ip.to_string(),
2589            status: resp.status().as_u16(),
2590            outcome: outcome.to_string(),
2591            latency_ms: elapsed.as_millis() as u64,
2592            edge_id: shipper.edge_id().to_string(),
2593        });
2594    }
2595    // Managed mode: count every finished request (proxied or rejected) toward the usage delta, and
2596    // — when the edge denied it — the drainable `blocked` figure. Cheap (relaxed atomic adds) and
2597    // inert unless a control plane drains it for reporting.
2598    metrics.add_usage_request(outcome);
2599    resp
2600}
2601
2602#[cfg(test)]
2603mod tests {
2604    use super::*;
2605
2606    fn headers_with(name: &'static str, value: &str) -> HeaderMap {
2607        let mut h = HeaderMap::new();
2608        h.insert(name, HeaderValue::from_str(value).unwrap());
2609        h
2610    }
2611
2612    #[test]
2613    fn rfc3339_matches_known_timestamps() {
2614        // A hand-rolled civil-time conversion is exactly the kind of code that is subtly wrong for
2615        // years because it is only ever called with "now" and nobody checks the answer.
2616        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
2617        assert_eq!(rfc3339(1), "1970-01-01T00:00:01Z");
2618        // A leap day, and the year-2000 leap (divisible by 400, so it IS a leap year — the case
2619        // the naive every-four-years rule gets right and the every-century rule gets wrong).
2620        assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
2621        // 2100 is NOT a leap year (divisible by 100, not by 400).
2622        assert_eq!(rfc3339(4_107_542_400), "2100-03-01T00:00:00Z");
2623        assert_eq!(rfc3339(1_234_567_890), "2009-02-13T23:31:30Z");
2624        // End of a day, and the first second of the next.
2625        assert_eq!(rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
2626        assert_eq!(rfc3339(1_767_225_600), "2026-01-01T00:00:00Z");
2627    }
2628
2629    #[test]
2630    fn rfc3339_is_always_a_fixed_width_sortable_string() {
2631        // A collector orders records lexically. Any field that is not zero-padded sorts wrongly the
2632        // moment it crosses a digit boundary, which would show up as records interleaving on the
2633        // 10th of a month and not before.
2634        for t in [
2635            0i64,
2636            1,
2637            951_782_400,
2638            1_234_567_890,
2639            1_767_225_600,
2640            4_107_542_400,
2641        ] {
2642            let s = rfc3339(t);
2643            assert_eq!(s.len(), 20, "{s}");
2644            assert!(s.ends_with('Z'), "{s}");
2645        }
2646    }
2647
2648    #[test]
2649    fn capture_for_span_redacts_content_when_dlp_is_configured() {
2650        // With a DLP engine, captured content is redacted before it can be emitted — even in
2651        // `report` mode, which does not rewrite the forwarded body. This is the safety guarantee for
2652        // gateway content capture (top-20 #14): PII/secrets never leave the box via a span.
2653        let dlp = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
2654            mode: "report".into(),
2655            detect_email: true,
2656            ..Default::default()
2657        })
2658        .unwrap()
2659        .map(Arc::new);
2660        assert!(dlp.is_some(), "report mode should build a DLP engine");
2661        let body = b"please email alice@example.com about the invoice";
2662
2663        let redacted = capture_for_span(dlp.as_ref(), body, 4096);
2664        assert!(
2665            !redacted.contains("alice@example.com"),
2666            "email must be redacted before capture: {redacted}"
2667        );
2668
2669        // Without a DLP engine, capture is verbatim (an explicit `capture_content` opt-in).
2670        let raw = capture_for_span(None, body, 4096);
2671        assert!(raw.contains("alice@example.com"));
2672
2673        // The size cap still applies to the captured content.
2674        assert!(capture_for_span(None, body, 8).len() < body.len());
2675    }
2676
2677    /// Drive a sequence of byte frames through a redact-mode `DlpStreamScanner` and return the
2678    /// concatenated emitted output (frames + final flush) as a string.
2679    fn run_stream_redact(frames: &[&[u8]]) -> String {
2680        let engine = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
2681            mode: "redact".into(),
2682            stream_redact: true,
2683            ..Default::default()
2684        })
2685        .unwrap()
2686        .unwrap();
2687        let mut scanner = DlpStreamScanner {
2688            engine: Arc::new(engine),
2689            metrics: Arc::new(Metrics::new()),
2690            redact: true,
2691            carry: Vec::new(),
2692        };
2693        let mut out = Vec::new();
2694        for f in frames {
2695            out.extend_from_slice(&scanner.redact_frame(f));
2696        }
2697        out.extend_from_slice(&scanner.flush());
2698        String::from_utf8(out).unwrap()
2699    }
2700
2701    #[test]
2702    fn stream_redaction_redacts_pii_split_across_frames() {
2703        // An email split across two SSE frames is still redacted whole (carry holds the boundary).
2704        let out = run_stream_redact(&[b"hello jane.d", b"oe@example.com bye"]);
2705        assert_eq!(out, "hello [REDACTED:email] bye");
2706    }
2707
2708    #[test]
2709    fn stream_redaction_passes_clean_text_unchanged() {
2710        let out = run_stream_redact(&[b"the quick brown ", b"fox jumps over the lazy dog"]);
2711        assert_eq!(out, "the quick brown fox jumps over the lazy dog");
2712    }
2713
2714    #[test]
2715    fn stream_redaction_handles_pii_at_end_via_flush() {
2716        // PII entirely within the final held-back tail is redacted by the end-of-stream flush.
2717        let out = run_stream_redact(&[b"ssn 123-45-6789"]);
2718        assert_eq!(out, "ssn [REDACTED:ssn]");
2719    }
2720
2721    #[test]
2722    fn path_prefix_matches_on_segment_boundary_only() {
2723        // Exact, sub-path, and query-boundary matches.
2724        assert!(path_prefix_matches("/api", "/api"));
2725        assert!(path_prefix_matches("/api/users", "/api"));
2726        assert!(path_prefix_matches("/api?x=1", "/api"));
2727        // A trailing-slash prefix matches its sub-paths.
2728        assert!(path_prefix_matches("/api/users", "/api/"));
2729        // Sibling paths sharing a textual prefix must NOT match.
2730        assert!(!path_prefix_matches("/apiary", "/api"));
2731        assert!(!path_prefix_matches("/apiary/honey", "/api"));
2732        // `/` matches everything.
2733        assert!(path_prefix_matches("/anything", "/"));
2734    }
2735
2736    #[test]
2737    fn client_ip_ignores_xff_when_untrusted() {
2738        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2739        let h = headers_with("x-forwarded-for", "1.2.3.4");
2740        // Untrusted: a directly reachable client must not be able to spoof its IP.
2741        assert_eq!(client_ip(&h, peer, false), peer.ip());
2742    }
2743
2744    #[test]
2745    fn client_ip_uses_first_xff_hop_when_trusted() {
2746        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2747        let h = headers_with("x-forwarded-for", "1.2.3.4, 5.6.7.8");
2748        assert_eq!(client_ip(&h, peer, true).to_string(), "1.2.3.4");
2749    }
2750
2751    #[test]
2752    fn client_ip_falls_back_to_peer_on_missing_or_garbage_xff() {
2753        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2754        assert_eq!(client_ip(&HeaderMap::new(), peer, true), peer.ip());
2755        let garbage = headers_with("x-forwarded-for", "not-an-ip");
2756        assert_eq!(client_ip(&garbage, peer, true), peer.ip());
2757    }
2758
2759    #[test]
2760    fn header_bytes_sums_names_and_values() {
2761        let mut h = HeaderMap::new();
2762        h.insert("a", HeaderValue::from_static("bb")); // 1 + 2
2763        h.insert("ccc", HeaderValue::from_static("dddd")); // 3 + 4
2764        assert_eq!(header_bytes(&h), 1 + 2 + 3 + 4);
2765    }
2766
2767    #[test]
2768    fn strip_hop_by_hop_removes_fixed_and_connection_named() {
2769        let mut h = HeaderMap::new();
2770        h.insert(
2771            "connection",
2772            HeaderValue::from_static("keep-alive, X-Custom-Hop"),
2773        );
2774        h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
2775        h.insert("x-custom-hop", HeaderValue::from_static("secret"));
2776        h.insert("content-type", HeaderValue::from_static("text/plain"));
2777        strip_hop_by_hop(&mut h);
2778        assert!(!h.contains_key("connection"));
2779        assert!(!h.contains_key("keep-alive"));
2780        // A header named by Connection is connection-specific and must be dropped.
2781        assert!(!h.contains_key("x-custom-hop"));
2782        // An end-to-end header is preserved.
2783        assert!(h.contains_key("content-type"));
2784    }
2785
2786    #[test]
2787    fn forwarded_proto_reflects_tls_and_trust() {
2788        let mut cfg = Config::default();
2789
2790        // We terminate TLS -> always https, regardless of any incoming header.
2791        cfg.tls.enabled = true;
2792        assert_eq!(
2793            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http")),
2794            "https"
2795        );
2796
2797        // Plain HTTP, untrusted: http, and an incoming XFP is NOT trusted.
2798        cfg.tls.enabled = false;
2799        cfg.server.trust_forwarded_for = false;
2800        assert_eq!(
2801            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
2802            "http"
2803        );
2804
2805        // Plain HTTP behind a trusted edge: preserve the edge's reported proto.
2806        cfg.server.trust_forwarded_for = true;
2807        assert_eq!(
2808            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
2809            "https"
2810        );
2811        assert_eq!(
2812            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http, https")),
2813            "http"
2814        );
2815        // Missing or unrecognized -> http.
2816        assert_eq!(forwarded_proto(&cfg, &HeaderMap::new()), "http");
2817        assert_eq!(
2818            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "garbage")),
2819            "http"
2820        );
2821    }
2822
2823    #[test]
2824    fn longest_route_picks_most_specific_prefix() {
2825        let mk = |p: &str| RouteLimiter {
2826            prefix: p.to_string(),
2827            limiter: Arc::new(RateLimiter::keyed(governor::Quota::per_second(
2828                std::num::NonZeroU32::new(1).unwrap(),
2829            ))),
2830        };
2831        let routes = vec![mk("/api/"), mk("/api/admin/")];
2832        assert_eq!(
2833            longest_route(&routes, "/api/admin/users").map(|r| r.prefix.as_str()),
2834            Some("/api/admin/")
2835        );
2836        assert_eq!(
2837            longest_route(&routes, "/api/things").map(|r| r.prefix.as_str()),
2838            Some("/api/")
2839        );
2840        assert!(longest_route(&routes, "/public").is_none());
2841    }
2842
2843    #[test]
2844    fn path_prefix_matches_on_segment_boundaries() {
2845        // A prefix without a trailing slash must not match a sibling path.
2846        assert!(path_prefix_matches("/api", "/api")); // exact
2847        assert!(path_prefix_matches("/api/users", "/api")); // segment boundary
2848        assert!(path_prefix_matches("/api?q=1", "/api")); // query boundary
2849        assert!(!path_prefix_matches("/apiary", "/api")); // sibling — must NOT match
2850                                                          // A trailing-slash prefix is a clean boundary by construction.
2851        assert!(path_prefix_matches("/api/users", "/api/"));
2852        assert!(!path_prefix_matches("/apiary", "/api/"));
2853        // "/" matches everything.
2854        assert!(path_prefix_matches("/anything", "/"));
2855    }
2856
2857    #[test]
2858    fn harden_cookie_adds_missing_flags() {
2859        let out = harden_cookie("sid=abc", true);
2860        assert!(out.contains("; Secure"), "{out}");
2861        assert!(out.contains("; HttpOnly"), "{out}");
2862        assert!(out.contains("; SameSite=Lax"), "{out}");
2863    }
2864
2865    #[test]
2866    fn harden_cookie_preserves_existing_attributes() {
2867        let out = harden_cookie("sid=abc; HttpOnly; SameSite=Strict", true);
2868        assert!(out.contains("; Secure"), "{out}");
2869        assert!(out.contains("SameSite=Strict"), "{out}");
2870        // existing SameSite isn't overridden, HttpOnly isn't duplicated
2871        assert!(!out.contains("SameSite=Lax"), "{out}");
2872        assert_eq!(out.matches("HttpOnly").count(), 1, "{out}");
2873    }
2874
2875    #[test]
2876    fn harden_cookie_value_resembling_an_attr_is_not_skipped() {
2877        // The value contains the substring "secure" but there is no Secure *attribute*;
2878        // it must still be added (regression guard for the token-vs-substring fix).
2879        let out = harden_cookie("session=securetoken", true);
2880        assert!(out.contains("; Secure"), "{out}");
2881    }
2882
2883    #[test]
2884    fn harden_cookie_skips_httponly_when_disabled() {
2885        // add_httponly=false → Secure + SameSite still added, but NOT HttpOnly. This is the
2886        // path for a JS-readable double-submit CSRF cookie (e.g. doneyet_csrf).
2887        let out = harden_cookie("doneyet_csrf=tok", false);
2888        assert!(out.contains("; Secure"), "{out}");
2889        assert!(out.contains("; SameSite=Lax"), "{out}");
2890        assert!(!out.to_ascii_lowercase().contains("httponly"), "{out}");
2891    }
2892
2893    #[test]
2894    fn cookie_name_exempt_matches_by_name_only() {
2895        let exempt = vec!["doneyet_csrf".to_string()];
2896        assert!(cookie_name_exempt(
2897            "doneyet_csrf=abc; Path=/; Secure",
2898            &exempt
2899        ));
2900        // a different cookie is not exempt; the value never triggers a match
2901        assert!(!cookie_name_exempt(
2902            "doneyet_auth=doneyet_csrf; Path=/",
2903            &exempt
2904        ));
2905        assert!(!cookie_name_exempt("sid=x", &exempt));
2906    }
2907
2908    #[test]
2909    fn security_headers_reflects_config_toggles() {
2910        // Defaults: every header present, CSP enforced (not report-only).
2911        let cfg = HeadersCfg::default();
2912        let got = security_headers(&cfg);
2913        let names: Vec<&str> = got.iter().map(|(n, _)| *n).collect();
2914        assert!(names.contains(&"X-Content-Type-Options"));
2915        assert!(names.contains(&"X-Frame-Options"));
2916        assert!(names.contains(&"Referrer-Policy"));
2917        assert!(names.contains(&"Permissions-Policy"));
2918        assert!(names.contains(&"Content-Security-Policy"));
2919        assert!(names.contains(&"Strict-Transport-Security"));
2920        assert!(!names.contains(&"Content-Security-Policy-Report-Only"));
2921
2922        // Disabling HSTS and clearing frame_options drops exactly those; report-only flips the
2923        // CSP header name and report_uri is appended to the value.
2924        let cfg = HeadersCfg {
2925            hsts: false,
2926            frame_options: String::new(),
2927            csp: "default-src 'self'".into(),
2928            csp_report_only: true,
2929            csp_report_uri: "/__edgeguard/csp-report".into(),
2930            ..HeadersCfg::default()
2931        };
2932        let got = security_headers(&cfg);
2933        let map: std::collections::HashMap<&str, String> =
2934            got.iter().map(|(n, v)| (*n, v.clone())).collect();
2935        assert!(!map.contains_key("Strict-Transport-Security"));
2936        assert!(!map.contains_key("X-Frame-Options"));
2937        assert!(!map.contains_key("Content-Security-Policy"));
2938        assert_eq!(
2939            map.get("Content-Security-Policy-Report-Only")
2940                .map(|s| s.as_str()),
2941            Some("default-src 'self'; report-uri /__edgeguard/csp-report")
2942        );
2943    }
2944}