Skip to main content

cratefield_core/
http.rs

1//! HTTP plumbing every venture router shares (issue #2): the problem+json
2//! `Json` and `Form` extractors, the request-id middleware that creates
3//! the [`Scope`], and the `/v1/*` security headers.
4
5use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, 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
22/// `x-request-id`: accepted from the client when it matches
23/// `^[A-Za-z0-9_-]{8,128}$`, otherwise generated as a ULID. Always set on
24/// the response (architecture section 6).
25pub const X_REQUEST_ID: &str = "x-request-id";
26
27/// Default request body limit for `/v1/*` JSON endpoints.
28pub const MAX_BODY_BYTES: usize = 64 * 1024;
29
30// `Duration::from_days` is unstable on the pinned toolchain
31// (`duration_constructors`), so this stays in seconds.
32#[allow(clippy::duration_suboptimal_units)]
33const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
34
35/// The character class and length bounds of an accepted request id.
36pub 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/// The per-request span's fields as plain data, attached to every
44/// response's extensions by the request-id layer so a runtime with no
45/// tracing dispatcher — Cloudflare — can still log them. `route` is the
46/// matched pattern, never the raw path (`""` when nothing matched). There
47/// is no `duration_ms`: wasm32 has no monotonic clock to measure it with.
48#[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/// State the request-id layer needs, resolved from `Ports` when the router
60/// is assembled.
61#[derive(Clone)]
62pub(crate) struct ScopeState {
63    pub defer: Arc<dyn Defer>,
64    pub id_gen: Arc<dyn IdGen>,
65    /// The one module this deployment serves, when it serves exactly one —
66    /// the sidecar shape (ADR 0009). Set, the response carries
67    /// `x-harness-module` next to [`crate::sidecar::X_HARNESS_API`]; the
68    /// host reads both on every forwarded response, which is how a sidecar
69    /// redeployed against a different contract is caught within one
70    /// request instead of at a cold start that isolates do not have.
71    pub module: Option<&'static str>,
72}
73
74/// Middleware: resolve the request id, build the [`Scope`] (request id,
75/// defer, tracing span), insert it into extensions, echo the id on the
76/// response.
77pub(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    // The one structured span per request (issue #14). `route`,
96    // `module`, `status` and `duration_ms` are recorded after the
97    // handler runs; no field ever carries an email (only `ip_hash`).
98    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    // `std::time::Instant::now()` panics on wasm32-unknown-unknown with
133    // "time not implemented on this platform", which took down every
134    // request on Workers — `/__health` included. There is no monotonic
135    // clock in that target, and `Date.now()` is frozen between I/O in
136    // workerd, so a wall-clock delta would read 0 and look measured.
137    // Timing is therefore recorded only where a real clock exists;
138    // Cloudflare's own request logs carry it on Workers.
139    #[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    // The identity stamp (issue #61): one insert, on every response, and
148    // never a cold-start subrequest. A sidecar is built and deployed
149    // separately, so the compile-time `Harness::build` contract check does
150    // not cover it; the host instead reads this header back on every
151    // forwarded response and refuses the prefix on a wrong number. That
152    // catches a sidecar redeployed under a warm host within one request —
153    // an isolate can outlive the sidecar for hours, so a cached cold-start
154    // verdict would keep serving a contract that no longer holds.
155    if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
156        response.headers_mut().insert(X_HARNESS_API, value);
157    }
158    // The module name rides along so a host mounting several sidecars can
159    // tell whose answer it is looking at. It is only well-defined when the
160    // deployment serves exactly one module — the sidecar shape — so a
161    // multi-module host stays silent rather than stamping a guess.
162    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
193/// Coarse user-agent family: the first product token, lowercased —
194/// enough to group browsers, bots and libraries without a UA parser.
195fn 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
209/// Middleware: `/v1/*` responses carry
210/// `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`,
211/// `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY` and
212/// `Content-Security-Policy: frame-ancestors 'none'` (architecture
213/// section 6). `/v1` is never meant to be framed: the API answers in
214/// JSON no page has business embedding, and the one document it serves —
215/// the magic-link confirmation — is HTML that must refuse to render
216/// inside a third party's frame, where a surrounding attacker page could
217/// clickjack the confirm button (issue #435).
218pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
219    let is_api = request.uri().path().starts_with("/v1/");
220    let mut response = next.run(request).await;
221    if is_api {
222        insert_no_store_headers(response.headers_mut());
223        insert_framing_headers(response.headers_mut());
224    }
225    response
226}
227
228/// Middleware for token-bearing URLs (issue #135). A `token` query
229/// parameter is the credential — confirmation links and
230/// `/ui/waitlist/status?token=…` authenticate with the URL itself, and
231/// `/ui/*` is outside the `/v1/*` rule above. Any response to a request
232/// whose query carries a `token` parameter therefore gets the same
233/// no-store headers at the root: no cache may keep the credential and no
234/// outbound navigation may leak it through `Referer`.
235pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
236    let carries_token = request.uri().query().is_some_and(|query| {
237        query
238            .split('&')
239            .any(|pair| pair == "token" || pair.starts_with("token="))
240    });
241    let mut response = next.run(request).await;
242    if carries_token {
243        insert_no_store_headers(response.headers_mut());
244    }
245    response
246}
247
248fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
249    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
250    headers.insert(
251        header::X_CONTENT_TYPE_OPTIONS,
252        HeaderValue::from_static("nosniff"),
253    );
254    headers.insert(
255        header::HeaderName::from_static("referrer-policy"),
256        HeaderValue::from_static("no-referrer"),
257    );
258}
259
260/// Frame-blocking headers, scoped to `/v1/*` only. Deliberately not
261/// folded into [`insert_no_store_headers`]: the token-bearing layer below
262/// shares that helper for *any* path whose query carries a `token` —
263/// including `/ui/*`, which sets its own `Content-Security-Policy` and
264/// `X-Frame-Options` (and deliberately none on `?fragment=1` fragments).
265/// Stamping the framing pair there would override the UI's answer; here
266/// it only ever lands on `/v1`, which has none of its own (issue #435).
267fn insert_framing_headers(headers: &mut axum::http::HeaderMap) {
268    // `insert`, not `append`: duplicated `X-Frame-Options` values are
269    // ill-defined and some browsers ignore the header entirely when it
270    // repeats, so the strictest value simply replaces whatever was there.
271    headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
272    // `append`, not `insert`: a browser handed several CSP policies
273    // enforces all of them — their intersection — so appending can only
274    // tighten whatever a `/v1` module set on its own response, where an
275    // `insert` would silently delete a fuller policy and substitute this
276    // one-directive one. No module sets a CSP under `/v1` today; this is
277    // written for the one that eventually does.
278    headers.append(
279        header::CONTENT_SECURITY_POLICY,
280        HeaderValue::from_static("frame-ancestors 'none'"),
281    );
282}
283
284/// A `Json` extractor and response whose rejections and serializations are
285/// problem+json (architecture section 6). Deserialization failures become a
286/// `400 validation-failed` problem listing the field.
287pub struct Json<T>(pub T);
288
289impl<T, S> FromRequest<S> for Json<T>
290where
291    T: DeserializeOwned,
292    S: Send + Sync,
293{
294    type Rejection = Problem;
295
296    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
297        let instance = request
298            .extensions()
299            .get::<Scope>()
300            .map(|scope| scope.request_id.clone());
301        match axum::Json::<T>::from_request(request, state).await {
302            Ok(axum::Json(value)) => Ok(Json(value)),
303            Err(rejection) => {
304                // Body reads fail through the shared 413 slug (the size
305                // limit); everything else is a 400 validation problem.
306                let mut problem = match &rejection {
307                    axum::extract::rejection::JsonRejection::BytesRejection(_) => {
308                        Problem::request_too_large()
309                    }
310                    _ => Problem::validation_failed(rejection.body_text()),
311                };
312                if let Some(instance) = instance {
313                    problem = problem.instance(&instance);
314                }
315                Err(problem)
316            }
317        }
318    }
319}
320
321impl<T: serde::Serialize> IntoResponse for Json<T> {
322    fn into_response(self) -> AxumResponse {
323        axum::Json(self.0).into_response()
324    }
325}
326
327/// A form (`application/x-www-form-urlencoded`) extractor whose
328/// rejections are problem+json with the same shape as [`Json`]'s: body
329/// reads fail through the shared 413 slug (the size limit), everything
330/// else is a 400 validation problem. Needed for cross-site `form_post`
331/// callbacks (issue #46).
332pub struct Form<T>(pub T);
333
334impl<T, S> FromRequest<S> for Form<T>
335where
336    T: DeserializeOwned,
337    S: Send + Sync,
338{
339    type Rejection = Problem;
340
341    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
342        let instance = request
343            .extensions()
344            .get::<Scope>()
345            .map(|scope| scope.request_id.clone());
346        match axum::Form::<T>::from_request(request, state).await {
347            Ok(axum::Form(value)) => Ok(Form(value)),
348            Err(rejection) => {
349                let mut problem = match &rejection {
350                    axum::extract::rejection::FormRejection::BytesRejection(_) => {
351                        Problem::request_too_large()
352                    }
353                    _ => Problem::validation_failed(rejection.body_text()),
354                };
355                if let Some(instance) = instance {
356                    problem = problem.instance(&instance);
357                }
358                Err(problem)
359            }
360        }
361    }
362}
363
364/// A `429 rate-limited` problem (architecture section 6). `Retry-After:
365/// <seconds>` comes from the [`Decision`]'s own pause, falling back to the
366/// quota's reset when the limiter reports a window but no pause; a known
367/// quota also rides the IETF draft `RateLimit-Limit`, `RateLimit-Remaining`
368/// and `RateLimit-Reset` headers (delta-seconds), so a well-behaved client
369/// can pace itself without probing (issue #538). A decision with no quota
370/// at all — a Workers Rate Limiting binding, or a failure resolved at the
371/// call site — is a bare `429` + `Retry-After`, exactly as before.
372pub fn rate_limited(decision: &Decision) -> AxumResponse {
373    let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
374    let mut response = problem.into_response();
375    let pause = decision
376        .retry_after
377        .or_else(|| decision.quota.as_ref().map(|quota| quota.reset));
378    if let Some(pause) = pause
379        && let Ok(value) = HeaderValue::from_str(&delta_seconds(pause))
380    {
381        response.headers_mut().insert(header::RETRY_AFTER, value);
382    }
383    if let Some(quota) = decision.quota.as_ref() {
384        for (name, value) in quota_headers(quota) {
385            if let Ok(value) = HeaderValue::from_str(&value) {
386                response
387                    .headers_mut()
388                    .insert(header::HeaderName::from_static(name), value);
389            }
390        }
391    }
392    response
393}
394
395/// A `429 usage/allowance-exhausted` problem (issue #588): a metered
396/// allowance is spent for the current period. The body carries extension
397/// members a caller acts on — the `meter`, the `limit`, the `used` total and
398/// the `period_end` as RFC 3339 — and `Retry-After` is the delta-seconds to
399/// that reset, the same header [`rate_limited`] sets.
400pub fn allowance_exhausted(meter: &str, outcome: &Exhausted) -> AxumResponse {
401    let mut response = allowance_exhausted_problem(meter, outcome).into_response();
402    if let Ok(value) = HeaderValue::from_str(&delta_seconds(outcome.retry_after)) {
403        response.headers_mut().insert(header::RETRY_AFTER, value);
404    }
405    response
406}
407
408/// The problem behind [`allowance_exhausted`], without the header — built in
409/// one place so the response and the harness's re-render under the serving
410/// venture's base carry the same members.
411fn allowance_exhausted_problem(meter: &str, outcome: &Exhausted) -> Problem {
412    Problem::new(&crate::problems::SLUGS.allowance_exhausted)
413        .with_extension("meter", meter)
414        .with_extension("limit", outcome.limit)
415        .with_extension("used", outcome.used)
416        .with_extension("period_end", crate::usage::rfc3339(outcome.period_end))
417}
418
419/// The `RateLimit-*` headers for one quota, as `(name, value)` pairs with
420/// lowercase static names.
421fn quota_headers(quota: &Quota) -> [(&'static str, String); 3] {
422    [
423        ("ratelimit-limit", quota.limit.to_string()),
424        ("ratelimit-remaining", quota.remaining.to_string()),
425        ("ratelimit-reset", delta_seconds(quota.reset)),
426    ]
427}
428
429/// Delta-seconds for `Retry-After` and `RateLimit-Reset`: rounded up, so a
430/// client never waits a shorter time than the truth, and never zero.
431fn delta_seconds(pause: Duration) -> String {
432    let millis = u64::try_from(pause.as_millis()).unwrap_or(u64::MAX);
433    millis.div_ceil(1_000).max(1).to_string()
434}
435
436/// CORS allowlist from the venture's origins; never a wildcard
437/// (architecture section 6). Tower-http echoes the matched origin rather
438/// than emitting `*`, and requests from other origins get no CORS headers.
439///
440/// `PUT` and `Authorization` are on the list because a browser client on
441/// the venture's own site is a cross-origin caller: `cf.js` is served
442/// from the API origin but runs on the site's, and registering for
443/// notifications is `PUT /v1/notifications/subscriptions` with a bearer
444/// token (issue #183). Left off, the preflight fails and the fetch never
445/// leaves the browser — with a console message and no server-side trace
446/// at all.
447///
448/// Credentials stay off, which is what keeps that safe: no cookie is ever
449/// attached to a cross-origin request, so allowing the header only lets a
450/// script send a token it already holds and had to be given deliberately.
451pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
452    use tower_http::cors::{AllowOrigin, CorsLayer};
453    let allowed: Vec<HeaderValue> = origins
454        .iter()
455        .filter_map(|origin| HeaderValue::from_str(origin).ok())
456        .collect();
457    CorsLayer::new()
458        .allow_origin(AllowOrigin::list(allowed))
459        .allow_methods([
460            axum::http::Method::GET,
461            axum::http::Method::POST,
462            axum::http::Method::PUT,
463            axum::http::Method::DELETE,
464            axum::http::Method::OPTIONS,
465        ])
466        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
467        .max_age(CORS_PREFLIGHT_MAX_AGE)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use axum::http::StatusCode;
474
475    fn header(response: &AxumResponse, name: &str) -> Option<String> {
476        response
477            .headers()
478            .get(name)
479            .and_then(|value| value.to_str().ok().map(str::to_owned))
480    }
481
482    #[test]
483    fn a_pause_becomes_retry_after_and_nothing_else() {
484        let response = rate_limited(&Decision {
485            ok: false,
486            retry_after: Some(Duration::from_secs(7)),
487            quota: None,
488        });
489        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
490        assert_eq!(header(&response, "retry-after").as_deref(), Some("7"));
491        assert!(header(&response, "ratelimit-limit").is_none());
492    }
493
494    #[test]
495    fn a_quota_rides_the_ratelimit_headers() {
496        let response = rate_limited(&Decision {
497            ok: false,
498            retry_after: None,
499            quota: Some(Quota {
500                limit: 30,
501                remaining: 0,
502                reset: Duration::from_secs(43),
503            }),
504        });
505        assert_eq!(header(&response, "retry-after").as_deref(), Some("43"));
506        assert_eq!(header(&response, "ratelimit-limit").as_deref(), Some("30"));
507        assert_eq!(
508            header(&response, "ratelimit-remaining").as_deref(),
509            Some("0")
510        );
511        assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("43"));
512    }
513
514    #[test]
515    fn the_quotas_reset_backfills_a_missing_retry_after() {
516        let response = rate_limited(&Decision {
517            ok: false,
518            retry_after: None,
519            quota: Some(Quota {
520                limit: 5,
521                remaining: 2,
522                reset: Duration::from_millis(1500),
523            }),
524        });
525        // Delta-seconds round up: a client never waits less than the truth.
526        assert_eq!(header(&response, "retry-after").as_deref(), Some("2"));
527        assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("2"));
528    }
529
530    #[test]
531    fn an_answerless_decision_is_a_bare_429() {
532        let response = rate_limited(&Decision {
533            ok: false,
534            retry_after: None,
535            quota: None,
536        });
537        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
538        assert!(header(&response, "retry-after").is_none());
539        assert!(header(&response, "ratelimit-limit").is_none());
540    }
541
542    fn exhausted() -> Exhausted {
543        Exhausted {
544            used: 100,
545            limit: 100,
546            period_end: time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("in range"),
547            retry_after: Duration::from_secs(120),
548        }
549    }
550
551    #[test]
552    fn an_exhausted_allowance_is_a_429_with_a_retry_after() {
553        let response = allowance_exhausted("mail_sent", &exhausted());
554        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
555        assert_eq!(header(&response, "retry-after").as_deref(), Some("120"));
556    }
557
558    #[test]
559    fn an_exhausted_allowance_carries_its_meter_limit_used_and_period_end() {
560        // Rendered under a venture base, the way the harness's outermost
561        // layer renders every problem: the type URI and the extension
562        // members must both survive (issue #588).
563        let body = allowance_exhausted_problem("mail_sent", &exhausted())
564            .body("https://api.test.example/problems/");
565        assert_eq!(
566            body["type"],
567            "https://api.test.example/problems/usage/allowance-exhausted"
568        );
569        assert_eq!(body["meter"], "mail_sent");
570        assert_eq!(body["limit"], 100);
571        assert_eq!(body["used"], 100);
572        assert_eq!(body["period_end"], "2027-01-15T08:00:00Z");
573        assert_eq!(body["status"], 429);
574    }
575}