Skip to main content

wire/server/
guard.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The entry decision in front of every route, and the headers every
7//! answer carries (`crates/wire/spec/Reception/Entry.lean` ยง8-94).
8//!
9//! The judgement is `reception::decide_entry`'s; this reads the three
10//! headers it asks about, answers a refusal with 403 before the route
11//! runs - before a socket upgrades, before a body is read - and stamps
12//! the listener's headers on what goes out.
13
14use std::sync::Arc;
15
16use axum::Router;
17use axum::extract::{Request, State};
18use axum::http::header::{HOST, ORIGIN};
19use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
20use axum::middleware::{Next, from_fn_with_state};
21use axum::response::{IntoResponse, Response};
22use kernel::{AxCode, AxError};
23
24use crate::reception::{Arrival, Entry, ListenerOrigins, PageHeaders, Presented, decide_entry};
25
26use super::config::refusal_text;
27
28/// The Fetch Metadata header the entry decision reads.
29const SEC_FETCH_SITE: &str = "sec-fetch-site";
30
31/// What one route's entry layer holds: which arrival the route is, and
32/// the listener's names.
33pub(crate) type Entrance = (Arrival, Arc<ListenerOrigins>);
34
35/// `routes` behind the entry decision of the listener `origins` names:
36/// every route of the table arrives as `arrival`, and an `OPTIONS`
37/// request at any of them is judged as a preflight. The remote listener
38/// puts its sockets and its page behind this (wire D56); the city's own
39/// table layers each route itself, because its routes arrive as
40/// different kinds.
41pub fn entered<S>(routes: Router<S>, arrival: Arrival, origins: &ListenerOrigins) -> Router<S>
42where
43    S: Clone + Send + Sync + 'static,
44{
45    let names = Arc::new(origins.clone());
46    routes
47        .route_layer(from_fn_with_state((arrival, Arc::clone(&names)), enter))
48        .layer(from_fn_with_state(names, preflight))
49}
50
51/// The layer in front of one route: an `OPTIONS` request is a preflight
52/// whatever route it names, anything else is the route's own arrival.
53pub(crate) async fn enter(
54    State((arrival, origins)): State<Entrance>,
55    request: Request,
56    next: Next,
57) -> Response {
58    let arrival = if request.method() == Method::OPTIONS {
59        Arrival::Preflight
60    } else {
61        arrival
62    };
63    match judged(&origins, &request, arrival) {
64        Some(refused) => refused,
65        None => next.run(request).await,
66    }
67}
68
69/// The layer in front of the whole table, for a preflight at a route
70/// whose method table has no `OPTIONS`: the decision still runs, so a
71/// foreign Host is told so before a preflight is.
72pub(crate) async fn preflight(
73    State(origins): State<Arc<ListenerOrigins>>,
74    request: Request,
75    next: Next,
76) -> Response {
77    if request.method() != Method::OPTIONS {
78        return next.run(request).await;
79    }
80    match judged(&origins, &request, Arrival::Preflight) {
81        Some(refused) => refused,
82        None => next.run(request).await,
83    }
84}
85
86/// The 403 a refused request is answered with, or nothing when it may
87/// go on.
88fn judged(origins: &ListenerOrigins, request: &Request, arrival: Arrival) -> Option<Response> {
89    let headers = request.headers();
90    let read = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
91    let presented = Presented {
92        host: read(HOST.as_str()),
93        origin: read(ORIGIN.as_str()),
94        sec_fetch_site: read(SEC_FETCH_SITE),
95    };
96    match decide_entry(origins, presented, arrival) {
97        Entry::Refused(why) => {
98            Some((StatusCode::FORBIDDEN, refusal_text(&why.error())).into_response())
99        }
100        Entry::Page | Entry::Caller(_) => None,
101    }
102}
103
104/// A listener's headers as HTTP headers, converted once.
105#[derive(Debug, Clone)]
106pub struct Stamp(Vec<(HeaderName, HeaderValue)>);
107
108impl Stamp {
109    /// Converts `headers`, with `Cache-Control: no-store` added when
110    /// `stored` is [`Stored::Never`].
111    ///
112    /// # Errors
113    /// A header this crate spelled is not a valid HTTP header.
114    pub(crate) fn of(headers: &PageHeaders, stored: Stored) -> Result<Self, AxError> {
115        let extra = match stored {
116            Stored::AsPage => None,
117            Stored::Never => Some(("cache-control", "no-store".to_owned())),
118        };
119        headers
120            .lines()
121            .iter()
122            .cloned()
123            .chain(extra)
124            .map(|(name, value)| {
125                let name = HeaderName::try_from(name).map_err(|_| unspellable(name))?;
126                let value = HeaderValue::try_from(value).map_err(|_| unspellable(name.as_str()))?;
127                Ok((name, value))
128            })
129            .collect::<Result<Vec<_>, AxError>>()
130            .map(Self)
131    }
132}
133
134/// Whether an answer may be kept by a cache: the page's bytes may, an
135/// answer to a request never.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub(crate) enum Stored {
138    AsPage,
139    Never,
140}
141
142/// The layer that stamps a listener's headers on every answer.
143pub(crate) async fn stamp(
144    State(stamp): State<Arc<Stamp>>,
145    request: Request,
146    next: Next,
147) -> Response {
148    let mut response = next.run(request).await;
149    let headers = response.headers_mut();
150    for (name, value) in &stamp.0 {
151        headers.insert(name.clone(), value.clone());
152    }
153    response
154}
155
156fn unspellable(name: &str) -> AxError {
157    AxError::failure(
158        AxCode::ConfigInvalid,
159        "spell the listener's response headers",
160        format!("`{name}` is not a valid HTTP header"),
161    )
162    .with_recovery("report this: the headers are this build's own text")
163}