1use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, StatusCode, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::module::HARNESS_API;
15use crate::ports::{Decision, Defer, IdGen, Quota};
16use crate::problem::Problem;
17use crate::scope::Scope;
18use crate::sidecar::{X_HARNESS_API, X_HARNESS_MODULE};
19use crate::usage::Exhausted;
20use tracing::info_span;
21
22pub const X_REQUEST_ID: &str = "x-request-id";
26
27pub const MAX_BODY_BYTES: usize = 64 * 1024;
29
30#[allow(clippy::duration_suboptimal_units)]
33const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
34
35pub fn request_id_is_valid(value: &str) -> bool {
37 (8..=128).contains(&value.len())
38 && value
39 .bytes()
40 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
49pub struct RequestSummary {
50 pub request_id: String,
51 pub method: String,
52 pub route: String,
53 pub module: String,
54 pub status: u16,
55 pub ip_hash: String,
56 pub ua_family: String,
57}
58
59#[derive(Clone)]
62pub(crate) struct ScopeState {
63 pub defer: Arc<dyn Defer>,
64 pub id_gen: Arc<dyn IdGen>,
65 pub module: Option<&'static str>,
72}
73
74pub(crate) async fn scope_layer(
78 axum::extract::State(state): axum::extract::State<ScopeState>,
79 mut request: Request,
80 next: Next,
81) -> AxumResponse {
82 use tracing::Instrument as _;
83 use tracing::field::Empty;
84
85 let incoming = request
86 .headers()
87 .get(X_REQUEST_ID)
88 .and_then(|value| value.to_str().ok())
89 .filter(|value| request_id_is_valid(value));
90 let request_id = match incoming {
91 Some(valid) => valid.to_owned(),
92 None => state.id_gen.ulid(),
93 };
94
95 let method = request.method().as_str().to_owned();
99 let ip_hash = crate::logging::subject_hash(
100 &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
101 );
102 let ua_family = request
103 .headers()
104 .get(header::USER_AGENT)
105 .and_then(|value| value.to_str().ok())
106 .map_or_else(|| "unknown".to_owned(), ua_family_of);
107 let span = info_span!(
108 "request",
109 request_id = %request_id,
110 method = %method,
111 route = Empty,
112 module = Empty,
113 status = Empty,
114 duration_ms = Empty,
115 ip_hash = %ip_hash,
116 ua_family = %ua_family,
117 );
118
119 let scope = Scope {
120 defer: Arc::clone(&state.defer),
121 span: span.clone(),
122 request_id: request_id.clone(),
123 };
124 request.extensions_mut().insert(scope);
125
126 let route = request
127 .extensions()
128 .get::<axum::extract::MatchedPath>()
129 .map(|matched| matched.as_str().to_owned())
130 .unwrap_or_default();
131
132 #[cfg(not(target_arch = "wasm32"))]
140 let started = std::time::Instant::now();
141 let future = next.run(request);
142 let mut response = future.instrument(span.clone()).await;
143
144 if let Ok(value) = HeaderValue::from_str(&request_id) {
145 response.headers_mut().insert(X_REQUEST_ID, value);
146 }
147 if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
156 response.headers_mut().insert(X_HARNESS_API, value);
157 }
158 if let Some(name) = state.module
163 && let Ok(value) = HeaderValue::from_str(name)
164 {
165 response.headers_mut().insert(X_HARNESS_MODULE, value);
166 }
167 let module = route
168 .strip_prefix("/v1/")
169 .and_then(|rest| rest.split('/').next())
170 .unwrap_or_default()
171 .to_owned();
172 let status = response.status().as_u16();
173 span.record("route", route.as_str());
174 span.record("module", module.as_str());
175 span.record("status", status);
176 #[cfg(not(target_arch = "wasm32"))]
177 {
178 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
179 span.record("duration_ms", duration_ms);
180 }
181 response.extensions_mut().insert(RequestSummary {
182 request_id,
183 method,
184 route,
185 module,
186 status,
187 ip_hash,
188 ua_family,
189 });
190 response
191}
192
193fn ua_family_of(user_agent: &str) -> String {
196 let token = user_agent
197 .split(['/', ' ', ';', '('])
198 .next()
199 .unwrap_or_default()
200 .to_lowercase();
201 let truncated: String = token.chars().take(24).collect();
202 if truncated.is_empty() {
203 "unknown".to_owned()
204 } else {
205 truncated
206 }
207}
208
209pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
220 let is_api = request.uri().path().starts_with("/v1/");
221 let mut response = next.run(request).await;
222 if is_api {
223 insert_no_store_headers(response.headers_mut());
224 insert_framing_headers(response.headers_mut());
225 }
226 response
227}
228
229pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
237 let carries_token = request.uri().query().is_some_and(|query| {
238 query
239 .split('&')
240 .any(|pair| pair == "token" || pair.starts_with("token="))
241 });
242 let mut response = next.run(request).await;
243 if carries_token {
244 insert_no_store_headers(response.headers_mut());
245 }
246 response
247}
248
249fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
250 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
251 headers.insert(
252 header::X_CONTENT_TYPE_OPTIONS,
253 HeaderValue::from_static("nosniff"),
254 );
255 if !keeps_own_referrer_policy(headers) {
256 headers.insert(
257 header::REFERRER_POLICY,
258 HeaderValue::from_static("no-referrer"),
259 );
260 }
261}
262
263fn keeps_own_referrer_policy(headers: &axum::http::HeaderMap) -> bool {
277 let mut values = headers.get_all(header::REFERRER_POLICY).iter();
278 match (values.next(), values.next()) {
279 (Some(value), None) => value
280 .to_str()
281 .is_ok_and(|policy| policy.trim().eq_ignore_ascii_case("same-origin")),
282 _ => false,
283 }
284}
285
286fn insert_framing_headers(headers: &mut axum::http::HeaderMap) {
294 headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
298 headers.append(
305 header::CONTENT_SECURITY_POLICY,
306 HeaderValue::from_static("frame-ancestors 'none'"),
307 );
308}
309
310pub struct Json<T>(pub T);
314
315impl<T, S> FromRequest<S> for Json<T>
316where
317 T: DeserializeOwned,
318 S: Send + Sync,
319{
320 type Rejection = Problem;
321
322 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
323 let instance = request
324 .extensions()
325 .get::<Scope>()
326 .map(|scope| scope.request_id.clone());
327 match axum::Json::<T>::from_request(request, state).await {
328 Ok(axum::Json(value)) => Ok(Json(value)),
329 Err(rejection) => {
330 let mut problem = match &rejection {
338 axum::extract::rejection::JsonRejection::BytesRejection(rejection)
339 if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE =>
340 {
341 Problem::request_too_large()
342 }
343 _ => Problem::validation_failed(rejection.body_text()),
344 };
345 if let Some(instance) = instance {
346 problem = problem.instance(&instance);
347 }
348 Err(problem)
349 }
350 }
351 }
352}
353
354impl<T: serde::Serialize> IntoResponse for Json<T> {
355 fn into_response(self) -> AxumResponse {
356 axum::Json(self.0).into_response()
357 }
358}
359
360pub struct Form<T>(pub T);
366
367impl<T, S> FromRequest<S> for Form<T>
368where
369 T: DeserializeOwned,
370 S: Send + Sync,
371{
372 type Rejection = Problem;
373
374 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
375 let instance = request
376 .extensions()
377 .get::<Scope>()
378 .map(|scope| scope.request_id.clone());
379 match axum::Form::<T>::from_request(request, state).await {
380 Ok(axum::Form(value)) => Ok(Form(value)),
381 Err(rejection) => {
382 let mut problem = match &rejection {
386 axum::extract::rejection::FormRejection::BytesRejection(rejection)
387 if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE =>
388 {
389 Problem::request_too_large()
390 }
391 _ => Problem::validation_failed(rejection.body_text()),
392 };
393 if let Some(instance) = instance {
394 problem = problem.instance(&instance);
395 }
396 Err(problem)
397 }
398 }
399 }
400}
401
402pub fn rate_limited(decision: &Decision) -> AxumResponse {
411 let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
412 let mut response = problem.into_response();
413 let pause = decision
414 .retry_after
415 .or_else(|| decision.quota.as_ref().map(|quota| quota.reset));
416 if let Some(pause) = pause
417 && let Ok(value) = HeaderValue::from_str(&delta_seconds(pause))
418 {
419 response.headers_mut().insert(header::RETRY_AFTER, value);
420 }
421 if let Some(quota) = decision.quota.as_ref() {
422 for (name, value) in quota_headers(quota) {
423 if let Ok(value) = HeaderValue::from_str(&value) {
424 response
425 .headers_mut()
426 .insert(header::HeaderName::from_static(name), value);
427 }
428 }
429 }
430 response
431}
432
433pub fn allowance_exhausted(meter: &str, outcome: &Exhausted) -> AxumResponse {
439 let mut response = allowance_exhausted_problem(meter, outcome).into_response();
440 if let Ok(value) = HeaderValue::from_str(&delta_seconds(outcome.retry_after)) {
441 response.headers_mut().insert(header::RETRY_AFTER, value);
442 }
443 response
444}
445
446fn allowance_exhausted_problem(meter: &str, outcome: &Exhausted) -> Problem {
450 Problem::new(&crate::problems::SLUGS.allowance_exhausted)
451 .with_extension("meter", meter)
452 .with_extension("limit", outcome.limit)
453 .with_extension("used", outcome.used)
454 .with_extension("period_end", crate::usage::rfc3339(outcome.period_end))
455}
456
457fn quota_headers(quota: &Quota) -> [(&'static str, String); 3] {
460 [
461 ("ratelimit-limit", quota.limit.to_string()),
462 ("ratelimit-remaining", quota.remaining.to_string()),
463 ("ratelimit-reset", delta_seconds(quota.reset)),
464 ]
465}
466
467fn delta_seconds(pause: Duration) -> String {
470 let millis = u64::try_from(pause.as_millis()).unwrap_or(u64::MAX);
471 millis.div_ceil(1_000).max(1).to_string()
472}
473
474pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
490 use tower_http::cors::{AllowOrigin, CorsLayer};
491 let allowed: Vec<HeaderValue> = origins
492 .iter()
493 .filter_map(|origin| HeaderValue::from_str(origin).ok())
494 .collect();
495 CorsLayer::new()
496 .allow_origin(AllowOrigin::list(allowed))
497 .allow_methods([
498 axum::http::Method::GET,
499 axum::http::Method::POST,
500 axum::http::Method::PUT,
501 axum::http::Method::DELETE,
502 axum::http::Method::OPTIONS,
503 ])
504 .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
505 .max_age(CORS_PREFLIGHT_MAX_AGE)
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use axum::http::StatusCode;
512
513 fn header(response: &AxumResponse, name: &str) -> Option<String> {
514 response
515 .headers()
516 .get(name)
517 .and_then(|value| value.to_str().ok().map(str::to_owned))
518 }
519
520 #[test]
525 fn only_a_handlers_same_origin_policy_survives_the_no_store_stamp() {
526 let stamped = |policy: Option<&'static str>| {
527 let mut headers = axum::http::HeaderMap::new();
528 if let Some(policy) = policy {
529 headers.insert(header::REFERRER_POLICY, HeaderValue::from_static(policy));
530 }
531 insert_no_store_headers(&mut headers);
532 let values: Vec<_> = headers
533 .get_all(header::REFERRER_POLICY)
534 .iter()
535 .map(|value| value.to_str().unwrap_or_default().to_owned())
536 .collect();
537 values.join(",")
538 };
539 assert_eq!(stamped(None), "no-referrer");
540 assert_eq!(stamped(Some("same-origin")), "same-origin");
541 assert_eq!(stamped(Some("Same-Origin")), "Same-Origin");
542 for weaker in [
543 "unsafe-url",
544 "origin",
545 "strict-origin-when-cross-origin",
546 "no-referrer-when-downgrade",
547 "same-origin, unsafe-url",
548 ] {
549 assert_eq!(stamped(Some(weaker)), "no-referrer", "{weaker} survived");
550 }
551 let mut headers = axum::http::HeaderMap::new();
553 headers.append(
554 header::REFERRER_POLICY,
555 HeaderValue::from_static("same-origin"),
556 );
557 headers.append(
558 header::REFERRER_POLICY,
559 HeaderValue::from_static("unsafe-url"),
560 );
561 insert_no_store_headers(&mut headers);
562 assert_eq!(headers.get_all(header::REFERRER_POLICY).iter().count(), 1);
563 assert_eq!(headers.get(header::REFERRER_POLICY).unwrap(), "no-referrer");
564 }
565
566 #[test]
567 fn a_pause_becomes_retry_after_and_nothing_else() {
568 let response = rate_limited(&Decision {
569 ok: false,
570 retry_after: Some(Duration::from_secs(7)),
571 quota: None,
572 });
573 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
574 assert_eq!(header(&response, "retry-after").as_deref(), Some("7"));
575 assert!(header(&response, "ratelimit-limit").is_none());
576 }
577
578 #[test]
579 fn a_quota_rides_the_ratelimit_headers() {
580 let response = rate_limited(&Decision {
581 ok: false,
582 retry_after: None,
583 quota: Some(Quota {
584 limit: 30,
585 remaining: 0,
586 reset: Duration::from_secs(43),
587 }),
588 });
589 assert_eq!(header(&response, "retry-after").as_deref(), Some("43"));
590 assert_eq!(header(&response, "ratelimit-limit").as_deref(), Some("30"));
591 assert_eq!(
592 header(&response, "ratelimit-remaining").as_deref(),
593 Some("0")
594 );
595 assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("43"));
596 }
597
598 #[test]
599 fn the_quotas_reset_backfills_a_missing_retry_after() {
600 let response = rate_limited(&Decision {
601 ok: false,
602 retry_after: None,
603 quota: Some(Quota {
604 limit: 5,
605 remaining: 2,
606 reset: Duration::from_millis(1500),
607 }),
608 });
609 assert_eq!(header(&response, "retry-after").as_deref(), Some("2"));
611 assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("2"));
612 }
613
614 #[test]
615 fn an_answerless_decision_is_a_bare_429() {
616 let response = rate_limited(&Decision {
617 ok: false,
618 retry_after: None,
619 quota: None,
620 });
621 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
622 assert!(header(&response, "retry-after").is_none());
623 assert!(header(&response, "ratelimit-limit").is_none());
624 }
625
626 fn exhausted() -> Exhausted {
627 Exhausted {
628 used: 100,
629 limit: 100,
630 period_end: time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("in range"),
631 retry_after: Duration::from_secs(120),
632 }
633 }
634
635 #[test]
636 fn an_exhausted_allowance_is_a_429_with_a_retry_after() {
637 let response = allowance_exhausted("mail_sent", &exhausted());
638 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
639 assert_eq!(header(&response, "retry-after").as_deref(), Some("120"));
640 }
641
642 struct BodyThatNeverArrives;
650
651 impl futures_core::Stream for BodyThatNeverArrives {
652 type Item = Result<bytes::Bytes, std::io::Error>;
653
654 fn poll_next(
655 self: std::pin::Pin<&mut Self>,
656 _: &mut std::task::Context<'_>,
657 ) -> std::task::Poll<Option<Self::Item>> {
658 std::task::Poll::Ready(Some(Err(std::io::Error::new(
659 std::io::ErrorKind::ConnectionReset,
660 "connection reset by peer",
661 ))))
662 }
663 }
664
665 fn request_with_a_dead_body(content_type: &'static str) -> Request {
666 Request::builder()
667 .method("POST")
668 .uri("/v1/thing")
669 .header(header::CONTENT_TYPE, content_type)
670 .body(Body::from_stream(BodyThatNeverArrives))
671 .expect("request builds")
672 }
673
674 #[pollster::test]
675 async fn a_body_that_never_arrives_is_a_400_not_a_413() {
676 let Err(problem) = Json::<serde_json::Value>::from_request(
677 request_with_a_dead_body("application/json"),
678 &(),
679 )
680 .await
681 else {
682 panic!("the body never arrives");
683 };
684 assert_eq!(
685 problem.status,
686 StatusCode::BAD_REQUEST,
687 "a failed body read is not request-too-large: {}",
688 problem.slug
689 );
690 assert_eq!(problem.slug, crate::problems::SLUGS.validation_failed.slug);
691 }
692
693 #[pollster::test]
695 async fn a_form_body_that_never_arrives_is_a_400_not_a_413() {
696 let Err(problem) = Form::<std::collections::HashMap<String, String>>::from_request(
697 request_with_a_dead_body("application/x-www-form-urlencoded"),
698 &(),
699 )
700 .await
701 else {
702 panic!("the body never arrives");
703 };
704 assert_eq!(
705 problem.status,
706 StatusCode::BAD_REQUEST,
707 "a failed body read is not request-too-large: {}",
708 problem.slug
709 );
710 }
711
712 #[test]
713 fn an_exhausted_allowance_carries_its_meter_limit_used_and_period_end() {
714 let body = allowance_exhausted_problem("mail_sent", &exhausted())
718 .body("https://api.test.example/problems/");
719 assert_eq!(
720 body["type"],
721 "https://api.test.example/problems/usage/allowance-exhausted"
722 );
723 assert_eq!(body["meter"], "mail_sent");
724 assert_eq!(body["limit"], 100);
725 assert_eq!(body["used"], 100);
726 assert_eq!(body["period_end"], "2027-01-15T08:00:00Z");
727 assert_eq!(body["status"], 429);
728 }
729}