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