use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use arc_swap::ArcSwap;
use axum::{
body::{Body, Bytes},
extract::{ConnectInfo, State},
http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode},
};
use governor::{clock::DefaultClock, state::keyed::DefaultKeyedStateStore, RateLimiter};
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::{Body as HttpBody, Frame, SizeHint};
use hyper_util::client::legacy::{connect::HttpConnector, Client};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
use tracing::{debug, info, warn};
use crate::auth::{AuthEngine, Challenge, Decision};
use crate::config::{Config, HeadersCfg};
use crate::limiter::{Admit, DistributedLimiter};
use crate::metrics::Metrics;
use crate::waf::{WafEngine, WafMode};
pub type KeyedLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub type StrLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
pub type UpstreamClient = Client<HttpConnector, Full<Bytes>>;
#[derive(Clone)]
pub struct AppState {
pub client: UpstreamClient,
pub metrics: Arc<Metrics>,
pub runtime: Arc<ArcSwap<Runtime>>,
pub cp: Option<Arc<crate::cp::CpClient>>,
pub quota: Arc<crate::cp::QuotaState>,
}
pub struct RouteLimiter {
pub prefix: String,
pub limiter: Arc<KeyedLimiter>,
}
pub struct Runtime {
pub cfg: Arc<Config>,
pub upstream_base: Arc<String>,
pub upstream_routes: Vec<(String, Arc<String>)>,
pub auth: AuthEngine,
pub waf: WafEngine,
pub cors: Option<crate::cors::CorsPolicy>,
pub access: Option<crate::access::AccessPolicy>,
pub distributed: Option<DistributedLimiter>,
pub ip_limiter: Option<Arc<KeyedLimiter>>,
pub route_limiters: Vec<RouteLimiter>,
pub key_limiter: Option<Arc<StrLimiter>>,
pub max_body: usize,
pub max_response_body: usize,
pub max_header_bytes: usize,
pub upstream_timeout: Option<Duration>,
pub stream_passthrough: bool,
pub websocket_passthrough: bool,
pub llm: Arc<crate::llm::LlmRuntime>,
pub budgets: Option<Arc<crate::budget::BudgetEngine>>,
pub keyvault: Option<Arc<crate::keyvault::KeyVault>>,
pub dlp: Option<Arc<crate::dlp::DlpEngine>>,
pub telemetry: Arc<crate::telemetry::TelemetryRuntime>,
pub alerts: Arc<crate::alert::AlertRuntime>,
}
impl Runtime {
pub fn pick_upstream(&self, path: &str) -> &str {
self.upstream_routes
.iter()
.filter(|(prefix, _)| path_prefix_matches(path, prefix))
.max_by_key(|(prefix, _)| prefix.len())
.map(|(_, base)| base.as_str())
.unwrap_or_else(|| self.upstream_base.as_str())
}
}
fn path_prefix_matches(path: &str, prefix: &str) -> bool {
if prefix == "/" {
return true;
}
match path.strip_prefix(prefix) {
Some(rest) => {
rest.is_empty()
|| prefix.ends_with('/')
|| rest.starts_with('/')
|| rest.starts_with('?')
}
None => false,
}
}
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
pub async fn handle(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
req: Request<Body>,
) -> Response<Body> {
let rt = state.runtime.load_full();
let origin = req
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let mut resp = handle_inner(&state, &rt, peer, req).await;
if let Some(origin) = &origin {
if let Some(cors) = &rt.cors {
cors.decorate_origin(origin, &mut resp);
}
}
resp
}
async fn handle_inner(
state: &AppState,
rt: &Runtime,
peer: SocketAddr,
req: Request<Body>,
) -> Response<Body> {
let started = Instant::now();
let m = &state.metrics;
let method = req.method().clone();
let path = req
.uri()
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| req.uri().path().to_string());
let ip = client_ip(req.headers(), peer, rt.cfg.server.trust_forwarded_for);
let rid = resolve_request_id(req.headers());
if req.uri().path().starts_with("/__edgeguard/") {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"not_found",
text(StatusCode::NOT_FOUND, "Not Found"),
);
}
if let Some(access) = &rt.access {
if !access.allowed(ip) {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"ip_denied",
text(StatusCode::FORBIDDEN, "Forbidden"),
);
}
}
if rt.max_header_bytes > 0 && header_bytes(req.headers()) > rt.max_header_bytes {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"header_too_large",
text(
StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
"Request Header Fields Too Large",
),
);
}
if rt.cfg.control_plane.enforce_quota && state.quota.blocked() {
let mut resp = text(StatusCode::TOO_MANY_REQUESTS, "Quota Exceeded");
let reset = state.quota.reset_epoch();
if reset > 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let retry_after = reset.saturating_sub(now).max(0);
if let Ok(v) = HeaderValue::from_str(&retry_after.to_string()) {
resp.headers_mut().insert(header::RETRY_AFTER, v);
}
}
return finish(m, &rid, &method, &path, ip, started, "over_quota", resp);
}
if rt.cfg.ratelimit.enabled {
if let Some(d) = &rt.distributed {
match d.check_ip_route(ip, &path).await {
Admit::Allowed => {}
Admit::Limited(scope) => {
m.record_ratelimit_hit(scope);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"rate_limited",
text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
);
}
Admit::Error => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"limiter_error",
text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
);
}
}
} else {
let (limiter, scope) = match longest_route(&rt.route_limiters, &path) {
Some(r) => (Some(r.limiter.as_ref()), "route"),
None => (rt.ip_limiter.as_deref(), "ip"),
};
if let Some(limiter) = limiter {
if limiter.check_key(&ip).is_err() {
m.record_ratelimit_hit(scope);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"rate_limited",
text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
);
}
}
}
}
if method == Method::OPTIONS {
if let Some(cors) = &rt.cors {
if let Some(resp) = cors.preflight_response(req.headers()) {
return finish(m, &rid, &method, &path, ip, started, "cors_preflight", resp);
}
}
}
let principal = match rt.auth.authorize(&rt.cfg.auth, req.headers()).await {
Decision::Allow(principal) => principal,
Decision::Deny(challenge) => {
let mut resp = text(StatusCode::UNAUTHORIZED, "Unauthorized");
let challenge_value = match challenge {
Challenge::Basic(c) => Some(c),
Challenge::Bearer => Some("Bearer".to_string()),
Challenge::None => None,
};
if let Some(c) = challenge_value {
if let Ok(v) = HeaderValue::from_str(&c) {
resp.headers_mut().insert(header::WWW_AUTHENTICATE, v);
}
}
return finish(m, &rid, &method, &path, ip, started, "unauthorized", resp);
}
};
if let Some(principal) = &principal {
let key_admit = if let Some(d) = &rt.distributed {
Some(d.check_key(principal).await)
} else {
rt.key_limiter.as_ref().map(|limiter| {
if limiter.check_key(principal).is_err() {
Admit::Limited("key")
} else {
Admit::Allowed
}
})
};
match key_admit {
Some(Admit::Limited(scope)) => {
m.record_ratelimit_hit(scope);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"rate_limited",
text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
);
}
Some(Admit::Error) => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"limiter_error",
text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
);
}
Some(Admit::Allowed) | None => {}
}
}
let allow = &rt.cfg.validation.allow_methods;
if !allow.is_empty()
&& !allow
.iter()
.any(|x| x.eq_ignore_ascii_case(method.as_str()))
{
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"method_not_allowed",
text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"),
);
}
if rt.websocket_passthrough && is_upgrade_request(req.headers()) {
let mut req = req;
if let Some(vault) = rt.keyvault.as_ref() {
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(str::trim);
match presented.and_then(|k| vault.lookup(k)) {
Some(entry) => {
if !entry.model_allowed(None) {
m.record_keyvault("denied_model");
warn!(key = %entry.label(), client_ip = %ip, "WebSocket upgrade denied: key has a model allowlist (model cannot be verified on upgrade connections)");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"forbidden",
text(StatusCode::FORBIDDEN, "Forbidden"),
);
}
match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
Ok(v) => {
m.record_keyvault("swapped");
req.headers_mut().insert(header::AUTHORIZATION, v);
}
Err(e) => {
warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"bad_gateway",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
}
}
None => {
m.record_keyvault("denied_key");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"unauthorized",
text(StatusCode::UNAUTHORIZED, "Unauthorized"),
);
}
}
}
return proxy_upgrade(state, rt, req, &rid, &method, &path, ip, started).await;
}
let (parts, body) = req.into_parts();
let traceparent = parts
.headers
.get("traceparent")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let llm_team: Option<String> = parts
.headers
.get(rt.cfg.llm.team_header.as_str())
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let mut body_bytes = match axum::body::to_bytes(body, rt.max_body).await {
Ok(b) => b,
Err(_) => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"payload_too_large",
text(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large"),
)
}
};
let ingress_bytes = header_bytes(&parts.headers).saturating_add(body_bytes.len());
if let Some(hit) = rt.waf.evaluate(&path, &parts.headers, &body_bytes) {
m.record_waf_hit(hit.class);
match rt.waf.mode() {
WafMode::Block => {
warn!(
rule = %hit.rule_id,
class = hit.class,
location = hit.location,
client_ip = %ip,
path = %path,
"WAF blocked request"
);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"forbidden",
text(StatusCode::FORBIDDEN, "Forbidden"),
);
}
WafMode::Report => warn!(
rule = %hit.rule_id,
class = hit.class,
location = hit.location,
client_ip = %ip,
path = %path,
"WAF rule matched (report-only)"
),
WafMode::Off => {}
}
}
let mut mask_map = crate::dlp::MaskMap::default();
if let Some(dlp) = rt.dlp.as_ref() {
if dlp.scan_request() {
let body_text = String::from_utf8_lossy(&body_bytes);
let findings = dlp.scan(&body_text);
if !findings.is_empty() {
for f in &findings {
m.record_dlp_finding(f.category);
}
match dlp.mode() {
crate::dlp::DlpMode::Block => {
m.record_dlp_blocked();
warn!(findings = findings.len(), client_ip = %ip, "LLM request blocked by DLP (inbound PII/secret)");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"forbidden",
text(StatusCode::FORBIDDEN, "Forbidden"),
);
}
crate::dlp::DlpMode::Redact => {
let redacted = if dlp.reversible() {
dlp.redact_reversible(&body_text, &findings, &mut mask_map)
} else {
dlp.redact(&body_text, &findings)
};
warn!(
findings = findings.len(),
reversible = dlp.reversible(),
"DLP redacted inbound request"
);
body_bytes = Bytes::from(redacted);
}
crate::dlp::DlpMode::Report => {
warn!(
findings = findings.len(),
"DLP findings in inbound request (report-only)"
)
}
crate::dlp::DlpMode::Off => {}
}
}
}
}
let llm_model = if rt.llm.enabled || rt.keyvault.is_some() || rt.budgets.is_some() {
crate::llm::parse_request_model(&body_bytes)
} else {
None
};
let mut upstream_auth: Option<HeaderValue> = None;
if let Some(vault) = rt.keyvault.as_ref() {
let presented = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(str::trim);
match presented.and_then(|k| vault.lookup(k)) {
Some(entry) => {
if !entry.model_allowed(llm_model.as_deref()) {
let model = llm_model.as_deref().unwrap_or("<missing>");
m.record_keyvault("denied_model");
warn!(key = %entry.label(), model = %model, client_ip = %ip, "LLM request denied: model off the key's egress allowlist");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"forbidden",
text(StatusCode::FORBIDDEN, "Forbidden"),
);
}
match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
Ok(v) => {
m.record_keyvault("swapped");
upstream_auth = Some(v);
}
Err(e) => {
warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"bad_gateway",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
}
}
None => {
m.record_keyvault("denied_key");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"unauthorized",
text(StatusCode::UNAUTHORIZED, "Unauthorized"),
);
}
}
}
if let Some(model) = llm_model.as_ref() {
if rt.llm.reject_unpriced(model) {
warn!(model = %model, client_ip = %ip, "LLM request denied: model not in price book (on_unpriced_model=block)");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"unpriced_model",
text(
StatusCode::PAYMENT_REQUIRED,
"Payment Required: model not in price book",
),
);
}
}
let mut budget_guard: Option<ReservationGuard> = None;
if let (Some(engine), Some(model)) = (rt.budgets.as_ref(), llm_model.as_ref()) {
let est_prompt = crate::llm::estimate_prompt_tokens(body_bytes.len());
let est_completion = crate::llm::parse_request_max_tokens(&body_bytes)
.unwrap_or(rt.cfg.llm.default_max_tokens);
let estimate = crate::budget::Spend {
tokens: est_prompt.saturating_add(est_completion),
cost_micros: rt
.llm
.cost_micros(
model,
&crate::llm::Usage {
prompt_tokens: est_prompt,
completion_tokens: est_completion,
..Default::default()
},
)
.unwrap_or(0),
};
let team = parts
.headers
.get(rt.cfg.llm.team_header.as_str())
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty());
let dims = crate::budget::Dims {
principal: principal.as_deref(),
model: crate::llm::canonical_model(model),
team,
};
match engine.reserve(dims, estimate).await {
crate::budget::Reserved::Ok(reservation) => {
for obs in reservation.observations() {
m.record_budget_consumed(&obs.name, obs.consumed_ratio);
rt.alerts.fire_budget_alert(&obs.name, obs.consumed_ratio);
}
m.record_budget_reconcile_failures(reservation.rollback_failures());
budget_guard = Some(ReservationGuard {
engine: Arc::clone(engine),
reservation: Some(reservation),
metrics: Arc::clone(m),
});
}
crate::budget::Reserved::Denied(denial) => {
m.record_budget_blocked(denial.scope.label());
m.record_budget_reconcile_failures(denial.rollback_failures);
warn!(budget = %denial.name, scope = %denial.scope.label(), model = %model, client_ip = %ip, "LLM request denied: budget exhausted");
let (status, body) = match denial.unit {
crate::budget::BudgetUnit::UsdMicros => (
StatusCode::PAYMENT_REQUIRED,
"Payment Required: budget exhausted",
),
crate::budget::BudgetUnit::Tokens => {
(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
}
};
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"over_budget",
text(status, body),
);
}
crate::budget::Reserved::Error { rollback_failures } => {
m.record_budget_reconcile_failures(rollback_failures);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"limiter_error",
text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
);
}
}
}
let uri = format!("{}{}", rt.pick_upstream(&path), path);
let mut up = Request::builder().method(parts.method.clone()).uri(&uri);
{
let headers = up.headers_mut().expect("builder headers");
let mut forwarded = parts.headers.clone();
strip_hop_by_hop(&mut forwarded);
forwarded.remove(header::CONTENT_LENGTH);
for (name, value) in forwarded.iter() {
if name == header::HOST {
continue; }
headers.insert(name.clone(), value.clone());
}
if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
headers.insert(HeaderName::from_static("x-forwarded-for"), v);
}
headers.insert(
HeaderName::from_static("x-forwarded-proto"),
HeaderValue::from_static(forwarded_proto(&rt.cfg, &parts.headers)),
);
if let Ok(v) = HeaderValue::from_str(&rid) {
headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
}
if let Some(v) = upstream_auth {
headers.insert(header::AUTHORIZATION, v);
}
}
let telem_input: Option<String> = (rt.telemetry.enabled && rt.telemetry.capture_content)
.then(|| capture_for_span(rt.dlp.as_ref(), &body_bytes, rt.telemetry.max_content_bytes));
let upstream_req = match up.body(Full::new(body_bytes)) {
Ok(r) => r,
Err(e) => {
warn!(error = %e, "failed to build upstream request");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"bad_gateway",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
};
let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
let timed_out = || {
warn!(upstream = %uri, "upstream timed out");
text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout")
};
let upstream_resp = match within(deadline, state.client.request(upstream_req)).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
warn!(error = %e, upstream = %uri, "upstream unreachable");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_error",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
Err(_) => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_timeout",
timed_out(),
)
}
};
let (mut resp_parts, resp_body) = upstream_resp.into_parts();
let dlp_blocks_response = rt
.dlp
.as_ref()
.is_some_and(|d| d.scan_response() && matches!(d.mode(), crate::dlp::DlpMode::Block));
if rt.stream_passthrough && is_event_stream(&resp_parts.headers) && !dlp_blocks_response {
strip_hop_by_hop(&mut resp_parts.headers);
resp_parts.headers.remove(header::CONTENT_LENGTH);
let header_egress = header_bytes(&resp_parts.headers);
let llm_meter = llm_model.as_ref().map(|model| {
let (engine, reservation) = match budget_guard.take() {
Some(mut g) => (Some(g.engine.clone()), g.reservation.take()),
None => (None, None),
};
let (telemetry, ctx) = if rt.telemetry.enabled {
(
Some(Arc::clone(&rt.telemetry)),
crate::telemetry::TraceContext::from_traceparent(traceparent.as_deref()),
)
} else {
(None, crate::telemetry::TraceContext::from_traceparent(None))
};
LlmStreamMeter {
model: model.clone(),
llm: Arc::clone(&rt.llm),
tail: Vec::new(),
engine,
reservation,
started,
first_at: None,
last_at: None,
telemetry,
ctx,
input: telem_input.clone(),
team: llm_team.clone(),
key: principal.clone(),
}
});
let reversible_stream = rt.dlp.as_ref().is_some_and(|d| d.reversible());
let dlp_scanner = if reversible_stream {
None
} else {
rt.dlp
.as_ref()
.filter(|d| d.scan_response())
.map(|d| DlpStreamScanner {
engine: Arc::clone(d),
metrics: Arc::clone(m),
redact: d.stream_redact(),
carry: Vec::new(),
})
};
let unmasker = reversible_stream.then(|| UnmaskStreamState {
map: std::mem::take(&mut mask_map),
carry: Vec::new(),
});
let body = Body::new(CountingBody::new(
resp_body,
Arc::clone(m),
ingress_bytes,
header_egress,
llm_meter,
dlp_scanner,
unmasker,
));
let mut response = Response::from_parts(resp_parts, body);
harden_response(&rt.cfg, &mut response);
return finish(m, &rid, &method, &path, ip, started, "ok", response);
}
let mut resp_bytes = if rt.max_response_body > 0 {
match within(
deadline,
Limited::new(resp_body, rt.max_response_body).collect(),
)
.await
{
Ok(Ok(c)) => c.to_bytes(),
Ok(Err(_)) => {
warn!(
limit = rt.max_response_body,
"upstream response exceeded max_response_body"
);
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_body_too_large",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
Err(_) => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_timeout",
timed_out(),
)
}
}
} else {
match within(deadline, resp_body.collect()).await {
Ok(Ok(c)) => c.to_bytes(),
Ok(Err(e)) => {
warn!(error = %e, "failed reading upstream body");
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_body_error",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
Err(_) => {
return finish(
m,
&rid,
&method,
&path,
ip,
started,
"upstream_timeout",
timed_out(),
)
}
}
};
strip_hop_by_hop(&mut resp_parts.headers);
resp_parts.headers.remove(header::CONTENT_LENGTH);
m.add_usage_bytes(
ingress_bytes,
header_bytes(&resp_parts.headers).saturating_add(resp_bytes.len()),
);
if let Some(model) = &llm_model {
let usage = if is_event_stream(&resp_parts.headers) {
crate::llm::parse_sse_usage(&resp_bytes)
} else {
crate::llm::parse_response_usage(&resp_bytes)
};
let actual = match usage {
Some(usage) => {
let cost = rt.llm.cost_micros(model, &usage);
let sample = crate::metrics::LlmSample {
tokens_in: usage.prompt_tokens,
tokens_out: usage.completion_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
cost_micros: cost,
};
m.record_llm_usage(model, sample);
m.record_llm_team_usage(llm_team.as_deref().unwrap_or("_none"), &sample);
m.record_llm_key_usage(principal.as_deref().unwrap_or("_anon"), &sample);
if rt.telemetry.enabled {
let (start_nanos, end_nanos) = wall_clock_span(started);
let output = rt.telemetry.capture_content.then(|| {
capture_for_span(
rt.dlp.as_ref(),
&resp_bytes,
rt.telemetry.max_content_bytes,
)
});
rt.telemetry.emit(crate::telemetry::SpanRecord {
ctx: crate::telemetry::TraceContext::from_traceparent(
traceparent.as_deref(),
),
name: "llm.chat".into(),
model: model.clone(),
provider: None,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
cost_micros: cost,
start_unix_nano: start_nanos,
end_unix_nano: end_nanos,
ttft: None,
tpot: None,
status_ok: resp_parts.status.is_success(),
input: telem_input.clone(),
output,
session_id: None,
});
}
crate::budget::Spend {
tokens: usage.total_tokens(),
cost_micros: cost.unwrap_or(0),
}
}
None => {
m.record_llm_no_usage();
crate::budget::Spend::default()
}
};
if let Some(guard) = budget_guard.take() {
guard.commit(actual).await;
}
}
let reversible_active = rt.dlp.as_ref().is_some_and(|d| d.reversible());
if reversible_active {
if !mask_map.is_empty() {
let restored = mask_map.unmask(&String::from_utf8_lossy(&resp_bytes));
resp_bytes = Bytes::from(restored);
}
} else if let Some(dlp) = rt.dlp.as_ref() {
if dlp.scan_response() {
let body_text = String::from_utf8_lossy(&resp_bytes);
let findings = dlp.scan(&body_text);
if !findings.is_empty() {
for f in &findings {
m.record_dlp_finding(f.category);
}
match dlp.mode() {
crate::dlp::DlpMode::Block => {
m.record_dlp_blocked();
warn!(
findings = findings.len(),
"DLP withheld response body (outbound PII/secret)"
);
resp_parts.status = StatusCode::FORBIDDEN;
resp_bytes =
Bytes::from_static(b"{\"error\":\"response withheld by DLP policy\"}");
resp_parts.headers.remove(header::CONTENT_TYPE);
resp_parts.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
}
crate::dlp::DlpMode::Redact => {
warn!(findings = findings.len(), "DLP redacted response body");
resp_bytes = Bytes::from(dlp.redact(&body_text, &findings));
}
crate::dlp::DlpMode::Report => {
warn!(
findings = findings.len(),
"DLP findings in response (report-only)"
)
}
crate::dlp::DlpMode::Off => {}
}
}
}
}
let mut response = Response::from_parts(resp_parts, Body::from(resp_bytes));
harden_response(&rt.cfg, &mut response);
finish(m, &rid, &method, &path, ip, started, "ok", response)
}
pub async fn ready(State(state): State<AppState>) -> StatusCode {
let rt = state.runtime.load();
let Some((host, port)) = rt.cfg.upstream_probe_addr() else {
return StatusCode::SERVICE_UNAVAILABLE;
};
match tokio::time::timeout(
Duration::from_secs(2),
TcpStream::connect((host.as_str(), port)),
)
.await
{
Ok(Ok(_)) => StatusCode::OK,
_ => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub async fn metrics_handler(State(state): State<AppState>) -> Response<Body> {
let body = state.metrics.render();
let mut resp = Response::new(Body::from(body));
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
);
resp
}
pub async fn csp_report(State(state): State<AppState>, body: Bytes) -> StatusCode {
state.metrics.record_csp_report();
if let Some(cp) = &state.cp {
if state.runtime.load().cfg.control_plane.forward_csp {
let cp = cp.clone();
let raw = body.clone();
tokio::spawn(async move { cp.forward_csp(&raw).await });
}
}
match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(report) => {
let directive = report
.get("csp-report")
.and_then(|r| {
r.get("violated-directive")
.or_else(|| r.get("effective-directive"))
})
.and_then(|v| v.as_str())
.unwrap_or("unknown");
debug!(target: "edgeguard::csp", directive, "CSP violation report");
}
Err(_) => warn!(
bytes = body.len(),
"CSP violation report with an unparseable body"
),
}
StatusCode::NO_CONTENT
}
const REQUEST_ID_HEADER: &str = "x-request-id";
fn resolve_request_id(headers: &HeaderMap) -> String {
if let Some(v) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
let v = v.trim();
if !v.is_empty() && v.len() <= 128 && v.bytes().all(|b| b.is_ascii_graphic()) {
return v.to_string();
}
}
uuid::Uuid::new_v4().to_string()
}
fn client_ip(headers: &HeaderMap, peer: SocketAddr, trust_forwarded: bool) -> IpAddr {
if trust_forwarded {
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(s) = xff.to_str() {
if let Some(first) = s.split(',').next() {
if let Ok(ip) = first.trim().parse::<IpAddr>() {
return ip;
}
}
}
}
}
peer.ip()
}
fn header_bytes(headers: &HeaderMap) -> usize {
headers
.iter()
.map(|(name, value)| name.as_str().len() + value.as_bytes().len())
.sum()
}
fn is_event_stream(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| {
v.split(';')
.next()
.map(str::trim)
.map(|ct| ct.eq_ignore_ascii_case("text/event-stream"))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn is_upgrade_request(headers: &HeaderMap) -> bool {
let conn_has_upgrade = headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
conn_has_upgrade && headers.contains_key(header::UPGRADE)
}
#[allow(clippy::too_many_arguments)]
async fn proxy_upgrade(
state: &AppState,
rt: &Runtime,
mut req: Request<Body>,
request_id: &str,
method: &Method,
path: &str,
ip: IpAddr,
started: Instant,
) -> Response<Body> {
let m = &state.metrics;
let client_upgrade = hyper::upgrade::on(&mut req);
let uri = format!("{}{}", rt.pick_upstream(path), path);
let mut up = Request::builder().method(req.method().clone()).uri(&uri);
{
let headers = up.headers_mut().expect("builder headers");
let upgrade = req.headers().get(header::UPGRADE).cloned();
let mut forwarded = req.headers().clone();
strip_hop_by_hop(&mut forwarded);
for (name, value) in forwarded.iter() {
if name == header::HOST {
continue;
}
headers.insert(name.clone(), value.clone());
}
headers.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
if let Some(v) = upgrade {
headers.insert(header::UPGRADE, v);
}
if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
headers.insert(HeaderName::from_static("x-forwarded-for"), v);
}
headers.insert(
HeaderName::from_static("x-forwarded-proto"),
HeaderValue::from_static(forwarded_proto(&rt.cfg, req.headers())),
);
if let Ok(v) = HeaderValue::from_str(request_id) {
headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
}
}
let upstream_req = match up.body(Full::new(Bytes::new())) {
Ok(r) => r,
Err(e) => {
warn!(error = %e, "failed to build upstream upgrade request");
return finish(
m,
request_id,
method,
path,
ip,
started,
"bad_gateway",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
};
let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
let timed_out = || {
warn!(upstream = %uri, "upstream timed out (upgrade)");
finish(
m,
request_id,
method,
path,
ip,
started,
"upstream_timeout",
text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout"),
)
};
let mut up_resp = match within(deadline, state.client.request(upstream_req)).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
warn!(error = %e, upstream = %uri, "upstream unreachable (upgrade)");
return finish(
m,
request_id,
method,
path,
ip,
started,
"upstream_error",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
Err(_) => return timed_out(),
};
if up_resp.status() != StatusCode::SWITCHING_PROTOCOLS {
let (mut parts, body) = up_resp.into_parts();
let body_fut = async {
if rt.max_response_body > 0 {
Limited::new(body, rt.max_response_body)
.collect()
.await
.map(|c| c.to_bytes())
.map_err(|_| ())
} else {
body.collect().await.map(|c| c.to_bytes()).map_err(|_| ())
}
};
let bytes = match within(deadline, body_fut).await {
Ok(Ok(b)) => b,
Ok(Err(())) => {
warn!("upstream upgrade-rejection body failed or exceeded max_response_body");
return finish(
m,
request_id,
method,
path,
ip,
started,
"bad_gateway",
text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
);
}
Err(_) => return timed_out(),
};
strip_hop_by_hop(&mut parts.headers);
parts.headers.remove(header::CONTENT_LENGTH);
let mut response = Response::from_parts(parts, Body::from(bytes));
harden_response(&rt.cfg, &mut response);
return finish(m, request_id, method, path, ip, started, "ok", response);
}
let upstream_upgrade = hyper::upgrade::on(&mut up_resp);
tokio::spawn(async move {
match tokio::join!(client_upgrade, upstream_upgrade) {
(Ok(client_io), Ok(up_io)) => {
let mut client_io = TokioIo::new(client_io);
let mut up_io = TokioIo::new(up_io);
if let Err(e) = tokio::io::copy_bidirectional(&mut client_io, &mut up_io).await {
debug!(error = %e, "websocket tunnel closed");
}
}
(c, u) => warn!(
client_ok = c.is_ok(),
upstream_ok = u.is_ok(),
"websocket upgrade did not complete"
),
}
});
let (mut parts, _body) = up_resp.into_parts();
let upgrade = parts.headers.get(header::UPGRADE).cloned();
strip_hop_by_hop(&mut parts.headers);
parts.headers.remove(header::CONTENT_LENGTH);
parts
.headers
.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
if let Some(v) = upgrade {
parts.headers.insert(header::UPGRADE, v);
}
let response = Response::from_parts(parts, Body::empty());
finish(
m,
request_id,
method,
path,
ip,
started,
"ws_upgrade",
response,
)
}
struct CountingBody<B> {
inner: B,
metrics: Arc<Metrics>,
ingress: usize,
egress: usize,
llm: Option<LlmStreamMeter>,
dlp: Option<DlpStreamScanner>,
unmask: Option<UnmaskStreamState>,
pending: Option<Frame<Bytes>>,
}
struct UnmaskStreamState {
map: crate::dlp::MaskMap,
carry: Vec<u8>,
}
const DLP_STREAM_CARRY: usize = 256;
struct DlpStreamScanner {
engine: Arc<crate::dlp::DlpEngine>,
metrics: Arc<Metrics>,
redact: bool,
carry: Vec<u8>,
}
impl DlpStreamScanner {
fn record_only(&mut self, data: &[u8]) {
let carry_len = self.carry.len();
let mut buf = std::mem::take(&mut self.carry);
buf.extend_from_slice(data);
let text = String::from_utf8_lossy(&buf);
for f in self.engine.scan_stream(&text) {
if f.end > carry_len {
self.metrics.record_dlp_finding(f.category);
}
}
let keep = buf.len().min(DLP_STREAM_CARRY);
self.carry = buf.split_off(buf.len() - keep);
}
fn redact_frame(&mut self, data: &[u8]) -> Vec<u8> {
let mut buf = std::mem::take(&mut self.carry);
buf.extend_from_slice(data);
let text = String::from_utf8_lossy(&buf).into_owned();
let findings = self.engine.scan_stream(&text);
let mut emit_to = text.len().saturating_sub(DLP_STREAM_CARRY);
for f in &findings {
if f.start < emit_to && f.end > emit_to {
emit_to = f.start;
}
}
while emit_to > 0 && !text.is_char_boundary(emit_to) {
emit_to -= 1;
}
let emit: Vec<crate::dlp::Finding> =
findings.into_iter().filter(|f| f.end <= emit_to).collect();
for f in &emit {
self.metrics.record_dlp_finding(f.category);
}
let out = self.engine.redact(&text[..emit_to], &emit).into_bytes();
self.carry = text.as_bytes()[emit_to..].to_vec();
out
}
fn flush(&mut self) -> Vec<u8> {
if self.carry.is_empty() {
return Vec::new();
}
let buf = std::mem::take(&mut self.carry);
let text = String::from_utf8_lossy(&buf).into_owned();
let findings = self.engine.scan_stream(&text);
for f in &findings {
self.metrics.record_dlp_finding(f.category);
}
self.engine.redact(&text, &findings).into_bytes()
}
}
const LLM_SSE_TAIL_CAP: usize = 16 * 1024;
struct LlmStreamMeter {
model: String,
llm: Arc<crate::llm::LlmRuntime>,
tail: Vec<u8>,
engine: Option<Arc<crate::budget::BudgetEngine>>,
reservation: Option<crate::budget::Reservation>,
started: Instant,
first_at: Option<Instant>,
last_at: Option<Instant>,
telemetry: Option<Arc<crate::telemetry::TelemetryRuntime>>,
ctx: crate::telemetry::TraceContext,
input: Option<String>,
team: Option<String>,
key: Option<String>,
}
struct ReservationGuard {
engine: Arc<crate::budget::BudgetEngine>,
reservation: Option<crate::budget::Reservation>,
metrics: Arc<Metrics>,
}
impl ReservationGuard {
async fn commit(mut self, actual: crate::budget::Spend) {
if let Some(reservation) = self.reservation.take() {
let failed = self.engine.reconcile(&reservation, actual).await;
self.metrics.record_budget_reconcile_failures(failed);
}
}
}
impl Drop for ReservationGuard {
fn drop(&mut self) {
if let Some(reservation) = self.reservation.take() {
let engine = Arc::clone(&self.engine);
let metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
let failed = engine.release(&reservation).await;
metrics.record_budget_reconcile_failures(failed);
});
}
}
}
impl<B> CountingBody<B> {
fn new(
inner: B,
metrics: Arc<Metrics>,
ingress: usize,
header_egress: usize,
llm: Option<LlmStreamMeter>,
dlp: Option<DlpStreamScanner>,
unmask: Option<UnmaskStreamState>,
) -> Self {
Self {
inner,
metrics,
ingress,
egress: header_egress,
llm,
dlp,
unmask,
pending: None,
}
}
fn push_meter_tail(&mut self, data: &[u8]) {
if let Some(meter) = self.llm.as_mut() {
if !data.is_empty() {
let now = Instant::now();
meter.first_at.get_or_insert(now);
meter.last_at = Some(now);
}
meter.tail.extend_from_slice(data);
if meter.tail.len() > LLM_SSE_TAIL_CAP {
let drop_n = meter.tail.len() - LLM_SSE_TAIL_CAP;
meter.tail.drain(..drop_n);
}
}
}
}
impl<B> HttpBody for CountingBody<B>
where
B: HttpBody<Data = Bytes> + Unpin,
{
type Data = Bytes;
type Error = B::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.as_mut().get_mut();
if let Some(frame) = this.pending.take() {
return Poll::Ready(Some(Ok(frame)));
}
match Pin::new(&mut this.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
let data = match frame.into_data() {
Ok(data) => data,
Err(non_data) => {
if let Some(scanner) = this.dlp.as_mut() {
if scanner.redact {
let out = scanner.flush();
if !out.is_empty() {
let out = Bytes::from(out);
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
this.pending = Some(non_data); return Poll::Ready(Some(Ok(Frame::data(out))));
}
}
}
if let Some(u) = this.unmask.as_mut() {
let out = u.map.flush_unmask(&mut u.carry);
if !out.is_empty() {
let out = Bytes::from(out);
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
this.pending = Some(non_data);
return Poll::Ready(Some(Ok(Frame::data(out))));
}
}
return Poll::Ready(Some(Ok(non_data)));
}
};
if let Some(u) = this.unmask.as_mut() {
let out = Bytes::from(u.map.unmask_stream(&mut u.carry, &data));
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
return Poll::Ready(Some(Ok(Frame::data(out))));
}
if this.dlp.as_ref().is_some_and(|s| s.redact) {
let out = Bytes::from(this.dlp.as_mut().unwrap().redact_frame(&data));
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
Poll::Ready(Some(Ok(Frame::data(out))))
} else {
this.egress = this.egress.saturating_add(data.len());
this.push_meter_tail(&data);
if let Some(scanner) = this.dlp.as_mut() {
scanner.record_only(&data);
}
Poll::Ready(Some(Ok(Frame::data(data))))
}
}
Poll::Ready(None) => {
if let Some(scanner) = this.dlp.as_mut() {
if scanner.redact {
let out = scanner.flush();
if !out.is_empty() {
let out = Bytes::from(out);
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
return Poll::Ready(Some(Ok(Frame::data(out))));
}
}
}
if let Some(u) = this.unmask.as_mut() {
let out = u.map.flush_unmask(&mut u.carry);
if !out.is_empty() {
let out = Bytes::from(out);
this.egress = this.egress.saturating_add(out.len());
this.push_meter_tail(&out);
return Poll::Ready(Some(Ok(Frame::data(out))));
}
}
Poll::Ready(None)
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Pending => Poll::Pending,
}
}
fn is_end_stream(&self) -> bool {
if self.pending.is_some() {
return false;
}
if let Some(scanner) = self.dlp.as_ref() {
if scanner.redact && !scanner.carry.is_empty() {
return false;
}
}
if let Some(u) = self.unmask.as_ref() {
if !u.carry.is_empty() {
return false;
}
}
self.inner.is_end_stream()
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
impl<B> Drop for CountingBody<B> {
fn drop(&mut self) {
self.metrics.add_usage_bytes(self.ingress, self.egress);
if let Some(meter) = self.llm.as_mut() {
let actual = match crate::llm::parse_sse_usage(&meter.tail) {
Some(usage) => {
let cost = meter.llm.cost_micros(&meter.model, &usage);
let sample = crate::metrics::LlmSample {
tokens_in: usage.prompt_tokens,
tokens_out: usage.completion_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
cost_micros: cost,
};
self.metrics.record_llm_usage(&meter.model, sample);
self.metrics
.record_llm_team_usage(meter.team.as_deref().unwrap_or("_none"), &sample);
self.metrics
.record_llm_key_usage(meter.key.as_deref().unwrap_or("_anon"), &sample);
let (ttft, tpot) = match meter.first_at {
Some(first) => {
let ttft = first.saturating_duration_since(meter.started);
let tpot = match meter.last_at {
Some(last) if usage.completion_tokens > 1 => {
let denom =
(usage.completion_tokens - 1).min(u32::MAX as u64) as u32;
Some(last.saturating_duration_since(first) / denom)
}
_ => None,
};
(Some(ttft), tpot)
}
None => (None, None),
};
if let Some(ttft) = ttft {
self.metrics.record_llm_latency(ttft, tpot);
}
if let Some(telemetry) = meter.telemetry.as_ref() {
let (start_nanos, end_nanos) = wall_clock_span(meter.started);
telemetry.emit(crate::telemetry::SpanRecord {
ctx: meter.ctx,
name: "llm.chat".into(),
model: meter.model.clone(),
provider: None,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
cost_micros: cost,
start_unix_nano: start_nanos,
end_unix_nano: end_nanos,
ttft,
tpot,
status_ok: true, input: meter.input.take(),
output: None, session_id: None,
});
}
crate::budget::Spend {
tokens: usage.total_tokens(),
cost_micros: cost.unwrap_or(0),
}
}
None => {
self.metrics.record_llm_no_usage();
crate::budget::Spend::default()
}
};
if let (Some(engine), Some(reservation)) =
(meter.engine.take(), meter.reservation.take())
{
let metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
let failed = engine.reconcile(&reservation, actual).await;
metrics.record_budget_reconcile_failures(failed);
});
}
}
}
}
fn capture_for_span(dlp: Option<&Arc<crate::dlp::DlpEngine>>, bytes: &[u8], max: usize) -> String {
let text = crate::telemetry::prepare_content(bytes, max);
match dlp {
Some(dlp) => {
let findings = dlp.scan(&text);
if findings.is_empty() {
text
} else {
dlp.redact(&text, &findings)
}
}
None => text,
}
}
fn wall_clock_span(started: Instant) -> (u64, u64) {
let end = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let start = end.saturating_sub(started.elapsed().as_nanos() as u64);
(start, end)
}
fn strip_hop_by_hop(headers: &mut HeaderMap) {
let connection_named: Vec<HeaderName> = headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.filter_map(|token| HeaderName::from_bytes(token.trim().as_bytes()).ok())
.collect();
for name in HOP_BY_HOP {
headers.remove(*name);
}
for name in connection_named {
headers.remove(name);
}
}
fn forwarded_proto(cfg: &Config, headers: &HeaderMap) -> &'static str {
if cfg.tls.enabled {
return "https";
}
if cfg.server.trust_forwarded_for {
if let Some(value) = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
match value.split(',').next().map(str::trim) {
Some(p) if p.eq_ignore_ascii_case("https") => return "https",
Some(p) if p.eq_ignore_ascii_case("http") => return "http",
_ => {}
}
}
}
"http"
}
fn longest_route<'a>(routes: &'a [RouteLimiter], path: &str) -> Option<&'a RouteLimiter> {
routes
.iter()
.filter(|r| path.starts_with(&r.prefix))
.max_by_key(|r| r.prefix.len())
}
pub const HSTS_VALUE: &str = "max-age=63072000; includeSubDomains";
pub fn security_headers(cfg: &HeadersCfg) -> Vec<(&'static str, String)> {
let mut out: Vec<(&'static str, String)> = Vec::with_capacity(6);
out.push(("X-Content-Type-Options", "nosniff".to_string()));
if !cfg.frame_options.is_empty() {
out.push(("X-Frame-Options", cfg.frame_options.clone()));
}
if !cfg.referrer_policy.is_empty() {
out.push(("Referrer-Policy", cfg.referrer_policy.clone()));
}
if !cfg.permissions_policy.is_empty() {
out.push(("Permissions-Policy", cfg.permissions_policy.clone()));
}
if !cfg.csp.is_empty() {
let mut value = cfg.csp.clone();
if !cfg.csp_report_uri.is_empty() {
value.push_str("; report-uri ");
value.push_str(&cfg.csp_report_uri);
}
let name = if cfg.csp_report_only {
"Content-Security-Policy-Report-Only"
} else {
"Content-Security-Policy"
};
out.push((name, value));
}
if cfg.hsts {
out.push(("Strict-Transport-Security", HSTS_VALUE.to_string()));
}
out
}
fn harden_response(cfg: &Config, resp: &mut Response<Body>) {
let h = resp.headers_mut();
for (name, value) in security_headers(&cfg.headers) {
if let (Ok(n), Ok(v)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(&value),
) {
h.insert(n, v);
}
}
for name in &cfg.headers.strip {
if let Ok(hn) = HeaderName::from_bytes(name.as_bytes()) {
h.remove(hn);
}
}
if cfg.headers.force_secure_cookies {
let cookies: Vec<HeaderValue> = h.get_all(header::SET_COOKIE).iter().cloned().collect();
if !cookies.is_empty() {
h.remove(header::SET_COOKIE);
for c in cookies {
if let Ok(s) = c.to_str() {
let add_httponly = cfg.headers.httponly_cookies
&& !cookie_name_exempt(s, &cfg.headers.httponly_cookie_exempt);
let hardened = harden_cookie(s, add_httponly);
if let Ok(v) = HeaderValue::from_str(&hardened) {
h.append(header::SET_COOKIE, v);
}
} else {
h.append(header::SET_COOKIE, c);
}
}
}
}
}
fn cookie_name(cookie: &str) -> &str {
cookie
.split(';')
.next()
.unwrap_or("")
.split('=')
.next()
.unwrap_or("")
.trim()
}
fn cookie_name_exempt(cookie: &str, exempt: &[String]) -> bool {
let name = cookie_name(cookie);
exempt.iter().any(|e| e == name)
}
fn harden_cookie(cookie: &str, add_httponly: bool) -> String {
let attrs: std::collections::HashSet<String> = cookie
.split(';')
.skip(1)
.filter_map(|p| p.trim().split('=').next())
.map(|k| k.trim().to_ascii_lowercase())
.collect();
let mut out = cookie.trim_end_matches(';').to_string();
if !attrs.contains("secure") {
out.push_str("; Secure");
}
if add_httponly && !attrs.contains("httponly") {
out.push_str("; HttpOnly");
}
if !attrs.contains("samesite") {
out.push_str("; SameSite=Lax");
}
out
}
async fn within<F: Future>(
deadline: Option<tokio::time::Instant>,
fut: F,
) -> Result<F::Output, tokio::time::error::Elapsed> {
match deadline {
Some(dl) => tokio::time::timeout_at(dl, fut).await,
None => Ok(fut.await),
}
}
fn text(status: StatusCode, msg: &str) -> Response<Body> {
let mut resp = Response::new(Body::from(msg.to_string()));
*resp.status_mut() = status;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
resp
}
#[allow(clippy::too_many_arguments)]
fn finish(
metrics: &Metrics,
request_id: &str,
method: &Method,
path: &str,
ip: IpAddr,
started: Instant,
outcome: &str,
mut resp: Response<Body>,
) -> Response<Body> {
if let Ok(v) = HeaderValue::from_str(request_id) {
resp.headers_mut().insert(REQUEST_ID_HEADER, v);
}
let elapsed = started.elapsed();
info!(
request_id,
%method,
path = %path,
client_ip = %ip,
status = resp.status().as_u16(),
outcome,
latency_ms = elapsed.as_millis() as u64,
"request"
);
metrics.record_request(outcome);
metrics.observe_latency(elapsed);
metrics.add_usage_request(outcome);
resp
}
#[cfg(test)]
mod tests {
use super::*;
fn headers_with(name: &'static str, value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(name, HeaderValue::from_str(value).unwrap());
h
}
#[test]
fn capture_for_span_redacts_content_when_dlp_is_configured() {
let dlp = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
mode: "report".into(),
detect_email: true,
..Default::default()
})
.unwrap()
.map(Arc::new);
assert!(dlp.is_some(), "report mode should build a DLP engine");
let body = b"please email alice@example.com about the invoice";
let redacted = capture_for_span(dlp.as_ref(), body, 4096);
assert!(
!redacted.contains("alice@example.com"),
"email must be redacted before capture: {redacted}"
);
let raw = capture_for_span(None, body, 4096);
assert!(raw.contains("alice@example.com"));
assert!(capture_for_span(None, body, 8).len() < body.len());
}
fn run_stream_redact(frames: &[&[u8]]) -> String {
let engine = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
mode: "redact".into(),
stream_redact: true,
..Default::default()
})
.unwrap()
.unwrap();
let mut scanner = DlpStreamScanner {
engine: Arc::new(engine),
metrics: Arc::new(Metrics::new()),
redact: true,
carry: Vec::new(),
};
let mut out = Vec::new();
for f in frames {
out.extend_from_slice(&scanner.redact_frame(f));
}
out.extend_from_slice(&scanner.flush());
String::from_utf8(out).unwrap()
}
#[test]
fn stream_redaction_redacts_pii_split_across_frames() {
let out = run_stream_redact(&[b"hello jane.d", b"oe@example.com bye"]);
assert_eq!(out, "hello [REDACTED:email] bye");
}
#[test]
fn stream_redaction_passes_clean_text_unchanged() {
let out = run_stream_redact(&[b"the quick brown ", b"fox jumps over the lazy dog"]);
assert_eq!(out, "the quick brown fox jumps over the lazy dog");
}
#[test]
fn stream_redaction_handles_pii_at_end_via_flush() {
let out = run_stream_redact(&[b"ssn 123-45-6789"]);
assert_eq!(out, "ssn [REDACTED:ssn]");
}
#[test]
fn path_prefix_matches_on_segment_boundary_only() {
assert!(path_prefix_matches("/api", "/api"));
assert!(path_prefix_matches("/api/users", "/api"));
assert!(path_prefix_matches("/api?x=1", "/api"));
assert!(path_prefix_matches("/api/users", "/api/"));
assert!(!path_prefix_matches("/apiary", "/api"));
assert!(!path_prefix_matches("/apiary/honey", "/api"));
assert!(path_prefix_matches("/anything", "/"));
}
#[test]
fn client_ip_ignores_xff_when_untrusted() {
let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
let h = headers_with("x-forwarded-for", "1.2.3.4");
assert_eq!(client_ip(&h, peer, false), peer.ip());
}
#[test]
fn client_ip_uses_first_xff_hop_when_trusted() {
let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
let h = headers_with("x-forwarded-for", "1.2.3.4, 5.6.7.8");
assert_eq!(client_ip(&h, peer, true).to_string(), "1.2.3.4");
}
#[test]
fn client_ip_falls_back_to_peer_on_missing_or_garbage_xff() {
let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
assert_eq!(client_ip(&HeaderMap::new(), peer, true), peer.ip());
let garbage = headers_with("x-forwarded-for", "not-an-ip");
assert_eq!(client_ip(&garbage, peer, true), peer.ip());
}
#[test]
fn header_bytes_sums_names_and_values() {
let mut h = HeaderMap::new();
h.insert("a", HeaderValue::from_static("bb")); h.insert("ccc", HeaderValue::from_static("dddd")); assert_eq!(header_bytes(&h), 1 + 2 + 3 + 4);
}
#[test]
fn strip_hop_by_hop_removes_fixed_and_connection_named() {
let mut h = HeaderMap::new();
h.insert(
"connection",
HeaderValue::from_static("keep-alive, X-Custom-Hop"),
);
h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
h.insert("x-custom-hop", HeaderValue::from_static("secret"));
h.insert("content-type", HeaderValue::from_static("text/plain"));
strip_hop_by_hop(&mut h);
assert!(!h.contains_key("connection"));
assert!(!h.contains_key("keep-alive"));
assert!(!h.contains_key("x-custom-hop"));
assert!(h.contains_key("content-type"));
}
#[test]
fn forwarded_proto_reflects_tls_and_trust() {
let mut cfg = Config::default();
cfg.tls.enabled = true;
assert_eq!(
forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http")),
"https"
);
cfg.tls.enabled = false;
cfg.server.trust_forwarded_for = false;
assert_eq!(
forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
"http"
);
cfg.server.trust_forwarded_for = true;
assert_eq!(
forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
"https"
);
assert_eq!(
forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http, https")),
"http"
);
assert_eq!(forwarded_proto(&cfg, &HeaderMap::new()), "http");
assert_eq!(
forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "garbage")),
"http"
);
}
#[test]
fn longest_route_picks_most_specific_prefix() {
let mk = |p: &str| RouteLimiter {
prefix: p.to_string(),
limiter: Arc::new(RateLimiter::keyed(governor::Quota::per_second(
std::num::NonZeroU32::new(1).unwrap(),
))),
};
let routes = vec![mk("/api/"), mk("/api/admin/")];
assert_eq!(
longest_route(&routes, "/api/admin/users").map(|r| r.prefix.as_str()),
Some("/api/admin/")
);
assert_eq!(
longest_route(&routes, "/api/things").map(|r| r.prefix.as_str()),
Some("/api/")
);
assert!(longest_route(&routes, "/public").is_none());
}
#[test]
fn path_prefix_matches_on_segment_boundaries() {
assert!(path_prefix_matches("/api", "/api")); assert!(path_prefix_matches("/api/users", "/api")); assert!(path_prefix_matches("/api?q=1", "/api")); assert!(!path_prefix_matches("/apiary", "/api")); assert!(path_prefix_matches("/api/users", "/api/"));
assert!(!path_prefix_matches("/apiary", "/api/"));
assert!(path_prefix_matches("/anything", "/"));
}
#[test]
fn harden_cookie_adds_missing_flags() {
let out = harden_cookie("sid=abc", true);
assert!(out.contains("; Secure"), "{out}");
assert!(out.contains("; HttpOnly"), "{out}");
assert!(out.contains("; SameSite=Lax"), "{out}");
}
#[test]
fn harden_cookie_preserves_existing_attributes() {
let out = harden_cookie("sid=abc; HttpOnly; SameSite=Strict", true);
assert!(out.contains("; Secure"), "{out}");
assert!(out.contains("SameSite=Strict"), "{out}");
assert!(!out.contains("SameSite=Lax"), "{out}");
assert_eq!(out.matches("HttpOnly").count(), 1, "{out}");
}
#[test]
fn harden_cookie_value_resembling_an_attr_is_not_skipped() {
let out = harden_cookie("session=securetoken", true);
assert!(out.contains("; Secure"), "{out}");
}
#[test]
fn harden_cookie_skips_httponly_when_disabled() {
let out = harden_cookie("doneyet_csrf=tok", false);
assert!(out.contains("; Secure"), "{out}");
assert!(out.contains("; SameSite=Lax"), "{out}");
assert!(!out.to_ascii_lowercase().contains("httponly"), "{out}");
}
#[test]
fn cookie_name_exempt_matches_by_name_only() {
let exempt = vec!["doneyet_csrf".to_string()];
assert!(cookie_name_exempt(
"doneyet_csrf=abc; Path=/; Secure",
&exempt
));
assert!(!cookie_name_exempt(
"doneyet_auth=doneyet_csrf; Path=/",
&exempt
));
assert!(!cookie_name_exempt("sid=x", &exempt));
}
#[test]
fn security_headers_reflects_config_toggles() {
let cfg = HeadersCfg::default();
let got = security_headers(&cfg);
let names: Vec<&str> = got.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"X-Content-Type-Options"));
assert!(names.contains(&"X-Frame-Options"));
assert!(names.contains(&"Referrer-Policy"));
assert!(names.contains(&"Permissions-Policy"));
assert!(names.contains(&"Content-Security-Policy"));
assert!(names.contains(&"Strict-Transport-Security"));
assert!(!names.contains(&"Content-Security-Policy-Report-Only"));
let cfg = HeadersCfg {
hsts: false,
frame_options: String::new(),
csp: "default-src 'self'".into(),
csp_report_only: true,
csp_report_uri: "/__edgeguard/csp-report".into(),
..HeadersCfg::default()
};
let got = security_headers(&cfg);
let map: std::collections::HashMap<&str, String> =
got.iter().map(|(n, v)| (*n, v.clone())).collect();
assert!(!map.contains_key("Strict-Transport-Security"));
assert!(!map.contains_key("X-Frame-Options"));
assert!(!map.contains_key("Content-Security-Policy"));
assert_eq!(
map.get("Content-Security-Policy-Report-Only")
.map(|s| s.as_str()),
Some("default-src 'self'; report-uri /__edgeguard/csp-report")
);
}
}