Skip to main content

wire/reception/
entry.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//! Whether one request may knock at this city's door at all, before any
7//! credential is read (`crates/wire/spec/Reception/Entry.lean` §8-94,
8//! wire D54).
9//!
10//! The names and origins a listener answers to are computed once, from
11//! the address it holds, into [`ListenerOrigins`]; the Host check, the
12//! Origin check, the URL a person is given and the residents' browser
13//! guard all read that one value.
14
15use std::net::{IpAddr, SocketAddr};
16
17use kernel::{AxCode, AxError};
18
19use super::Door;
20
21/// The one value `Sec-Fetch-Site` may carry on a request this city
22/// answers. A page on another port of this machine sends `same-site`.
23const SAME_ORIGIN: &str = "same-origin";
24
25/// The port a browser leaves out of `Host` and `Origin`.
26const DEFAULT_HTTP_PORT: u16 = 80;
27
28/// The port a browser leaves out of an `https` address.
29const DEFAULT_HTTPS_PORT: &str = ":443";
30
31/// The names and origins one listener answers to, computed once from
32/// the address it holds.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ListenerOrigins {
35    hosts: Hosts,
36    url: String,
37}
38
39/// Which `Host` values a listener admits.
40#[derive(Debug, Clone, PartialEq, Eq)]
41enum Hosts {
42    /// The listener's own names, each with its port, and the origins of
43    /// the pages it serves.
44    Named {
45        names: Vec<String>,
46        origins: Vec<String>,
47    },
48    /// A listener on every interface: any IP literal with this port, the
49    /// unspecified address itself excepted, and the loopback names.
50    AnyAddress { port: u16, loopback: Vec<String> },
51}
52
53impl ListenerOrigins {
54    /// The names a listener bound to `local` answers to.
55    #[must_use]
56    pub fn of(local: SocketAddr) -> Self {
57        let port = local.port();
58        let loopback = || {
59            ["127.0.0.1", "localhost", "[::1]"]
60                .into_iter()
61                .flat_map(|name| spelled(name, port))
62                .collect::<Vec<_>>()
63        };
64        let ip = local.ip();
65        let (hosts, url_host) = if ip.is_loopback() {
66            let shown = match ip {
67                IpAddr::V4(_) => "127.0.0.1".to_owned(),
68                IpAddr::V6(_) => "[::1]".to_owned(),
69            };
70            (Hosts::named(loopback()), shown)
71        } else if ip.is_unspecified() {
72            (
73                Hosts::AnyAddress {
74                    port,
75                    loopback: loopback(),
76                },
77                "127.0.0.1".to_owned(),
78            )
79        } else {
80            let name = literal(ip);
81            (Hosts::named(spelled(&name, port)), name)
82        };
83        Self {
84            hosts,
85            url: format!("http://{url_host}:{port}"),
86        }
87    }
88
89    /// The names the remote listener bound to `local` answers to: its
90    /// loopback names, and the host of `public`, the `https://` address
91    /// its route gave (wire D56).
92    #[must_use]
93    pub fn routed(local: SocketAddr, public: &str) -> Self {
94        let mut listener = Self::of(local);
95        if let (Some(name), Hosts::Named { names, origins }) =
96            (public_name(public), &mut listener.hosts)
97        {
98            origins.push(format!("https://{name}"));
99            names.push(name);
100        }
101        listener
102    }
103
104    /// The address a person on this machine opens: `http://127.0.0.1:<port>`
105    /// for a loopback or every-interface listener. One spelling, because a
106    /// browser keeps its device key per origin and a second spelling of
107    /// the same listener is a second origin with no key in it.
108    #[must_use]
109    pub fn url(&self) -> &str {
110        &self.url
111    }
112
113    /// Whether `host`, as a request's `Host` header spells it, names this
114    /// listener.
115    #[must_use]
116    pub fn admits_host(&self, host: &str) -> bool {
117        match &self.hosts {
118            Hosts::Named { names, .. } => names.iter().any(|name| name.eq_ignore_ascii_case(host)),
119            Hosts::AnyAddress { port, loopback } => {
120                loopback.iter().any(|name| name.eq_ignore_ascii_case(host))
121                    || ip_literal_on(host, *port)
122            }
123        }
124    }
125
126    /// Whether `origin` is one of this listener's own, for a request whose
127    /// admitted `Host` is `host`.
128    ///
129    /// A named listener admits any of its origins; a listener on every
130    /// interface admits only the origin of the host the request was sent
131    /// to, because "any IP literal" would let a page on another machine of
132    /// the network in.
133    #[must_use]
134    pub fn admits_origin(&self, origin: &str, host: &str) -> bool {
135        match &self.hosts {
136            Hosts::Named { origins, .. } => origins
137                .iter()
138                .any(|listed| listed.eq_ignore_ascii_case(origin)),
139            Hosts::AnyAddress { .. } => origin
140                .strip_prefix("http://")
141                .is_some_and(|named| named.eq_ignore_ascii_case(host) && self.admits_host(named)),
142        }
143    }
144}
145
146impl Hosts {
147    /// A listener answering to `names`, whose pages are served over
148    /// `http` under the same names.
149    fn named(names: Vec<String>) -> Self {
150        let origins = names.iter().map(|name| format!("http://{name}")).collect();
151        Self::Named { names, origins }
152    }
153}
154
155/// The host of a route's `https://` address as a browser writes it in
156/// `Host`: lowercase, without `:443`. `None` when the address is not
157/// `https` or has no host (wire D56).
158fn public_name(public: &str) -> Option<String> {
159    let (scheme, rest) = public.split_once("://")?;
160    let authority = rest.split(['/', '?']).next()?.to_ascii_lowercase();
161    let name = authority
162        .strip_suffix(DEFAULT_HTTPS_PORT)
163        .map_or(authority.as_str(), |bare| bare)
164        .to_owned();
165    (scheme.eq_ignore_ascii_case("https") && !name.is_empty()).then_some(name)
166}
167
168/// The name `host:port`, and the bare name too when the port is the one a
169/// browser leaves out.
170fn spelled(name: &str, port: u16) -> Vec<String> {
171    let mut names = vec![format!("{name}:{port}")];
172    if port == DEFAULT_HTTP_PORT {
173        names.push(name.to_owned());
174    }
175    names
176}
177
178/// An address as a `Host` header writes it: IPv6 in brackets.
179fn literal(ip: IpAddr) -> String {
180    match ip {
181        IpAddr::V4(v4) => v4.to_string(),
182        IpAddr::V6(v6) => format!("[{v6}]"),
183    }
184}
185
186/// Whether `host` is an IP literal other than the unspecified address,
187/// carrying `port` (or no port, when `port` is the default one).
188fn ip_literal_on(host: &str, port: u16) -> bool {
189    let address = host.parse::<SocketAddr>().ok().or_else(|| {
190        (port == DEFAULT_HTTP_PORT)
191            .then(|| {
192                host.trim_start_matches('[')
193                    .trim_end_matches(']')
194                    .parse::<IpAddr>()
195                    .ok()
196            })
197            .flatten()
198            .map(|ip| SocketAddr::new(ip, port))
199    });
200    address.is_some_and(|address| address.port() == port && !address.ip().is_unspecified())
201}
202
203/// The response headers a listener answers with, as names and values;
204/// the shell turns them into HTTP headers once per listener.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct PageHeaders {
207    lines: Vec<(&'static str, String)>,
208}
209
210impl PageHeaders {
211    /// The headers every listener answers with: the city's own port and
212    /// the remote listener serve the same page under the same headers
213    /// (wire D20). `connect-src 'self'` admits the page's socket on its
214    /// own host and port, `ws:` from an `http:` page and `wss:` from an
215    /// `https:` one, in every engine that has the WebCrypto Ed25519 a
216    /// paired browser needs.
217    #[must_use]
218    pub fn every_listener() -> Self {
219        let policy = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; \
220                      style-src 'self' 'unsafe-inline'; connect-src 'self'; \
221                      img-src 'self' data: blob:; worker-src 'self' blob:; object-src 'none'; \
222                      base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
223        Self {
224            lines: vec![
225                ("content-security-policy", policy.to_owned()),
226                ("x-frame-options", "DENY".to_owned()),
227                ("x-content-type-options", "nosniff".to_owned()),
228                ("referrer-policy", "no-referrer".to_owned()),
229                ("cross-origin-opener-policy", "same-origin".to_owned()),
230                ("cross-origin-resource-policy", "same-origin".to_owned()),
231            ],
232        }
233    }
234
235    /// Every header, in the order it is written.
236    #[must_use]
237    pub fn lines(&self) -> &[(&'static str, String)] {
238        &self.lines
239    }
240}
241
242/// The three headers of a request that say who is knocking. One value,
243/// because the entry decision reads all three together.
244#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
245pub struct Presented<'a> {
246    pub host: Option<&'a str>,
247    pub origin: Option<&'a str>,
248    pub sec_fetch_site: Option<&'a str>,
249}
250
251/// Which route a request arrived at, as the route table names it.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum Arrival {
254    /// A GET of the client bundle.
255    Page,
256    /// An `OPTIONS` request at any route.
257    Preflight,
258    /// The socket upgrade at `/ws`.
259    Socket,
260    /// One of the HTTP doors that act.
261    Door(Door),
262    /// `/pair`, `/session/challenge` or `/session`.
263    Pairing,
264}
265
266/// Who is knocking, once the entry decision has let them knock.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Caller {
269    /// A program on this machine: it sends no `Origin`.
270    Native,
271    /// A page served by this listener.
272    Browser,
273}
274
275/// The verdict of [`decide_entry`].
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Entry {
278    /// The client bundle, served with this listener's headers and without
279    /// a credential.
280    Page,
281    /// A caller who may now present a credential.
282    Caller(Caller),
283    /// Refused with 403 before anything else happens.
284    Refused(EntryRefusal),
285}
286
287/// Why a request was refused at the entry.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum EntryRefusal {
290    ForeignHost,
291    Preflight,
292    ForeignOrigin,
293    CrossSite,
294    NativePairing,
295}
296
297impl EntryRefusal {
298    /// The refusal a person or a program reads.
299    #[must_use]
300    pub fn error(self) -> AxError {
301        let (subject, recovery) = match self {
302            Self::ForeignHost => (
303                "the request names a host this listener is not",
304                "open the address the city printed when it started",
305            ),
306            Self::Preflight => (
307                "this city answers no cross-origin request",
308                "send the request from the city's own page, or from a program on this machine",
309            ),
310            Self::ForeignOrigin => (
311                "the request comes from a page this city did not serve",
312                "open the address the city printed when it started",
313            ),
314            Self::CrossSite => (
315                "the browser says the request comes from another site",
316                "open the address the city printed when it started",
317            ),
318            Self::NativePairing => (
319                "pairing is for a browser page",
320                "a program on this machine presents the city's key from its key file instead",
321            ),
322        };
323        AxError::failure(AxCode::GateDenied, "enter the city's door", subject)
324            .with_recovery(recovery)
325    }
326}
327
328/// Decides whether one request may knock at all, in the order of
329/// `crates/wire/spec/Reception/Entry.lean` §8-94: Host, page, preflight,
330/// Origin, `Sec-Fetch-Site`, then the pairing routes for browsers only.
331/// Pure; the credential is judged after this, by [`super::Keys`].
332#[must_use]
333pub fn decide_entry(
334    origins: &ListenerOrigins,
335    presented: Presented<'_>,
336    arrival: Arrival,
337) -> Entry {
338    let Some(host) = presented.host.filter(|host| origins.admits_host(host)) else {
339        return Entry::Refused(EntryRefusal::ForeignHost);
340    };
341    let knocking = match arrival {
342        Arrival::Page => return Entry::Page,
343        Arrival::Preflight => return Entry::Refused(EntryRefusal::Preflight),
344        Arrival::Socket | Arrival::Door(_) | Arrival::Pairing => arrival,
345    };
346    let caller = match presented.origin {
347        None => Caller::Native,
348        Some(origin) if origins.admits_origin(origin, host) => Caller::Browser,
349        Some(_) => return Entry::Refused(EntryRefusal::ForeignOrigin),
350    };
351    if presented
352        .sec_fetch_site
353        .is_some_and(|site| !site.eq_ignore_ascii_case(SAME_ORIGIN))
354    {
355        return Entry::Refused(EntryRefusal::CrossSite);
356    }
357    match (knocking, caller) {
358        (Arrival::Pairing, Caller::Native) => Entry::Refused(EntryRefusal::NativePairing),
359        (
360            Arrival::Page
361            | Arrival::Preflight
362            | Arrival::Socket
363            | Arrival::Door(_)
364            | Arrival::Pairing,
365            Caller::Native | Caller::Browser,
366        ) => Entry::Caller(caller),
367    }
368}
369
370#[cfg(test)]
371#[allow(
372    clippy::unwrap_used,
373    clippy::expect_used,
374    clippy::panic,
375    clippy::indexing_slicing,
376    reason = "test code"
377)]
378mod tests {
379    use super::*;
380
381    fn loopback() -> ListenerOrigins {
382        ListenerOrigins::of("127.0.0.1:8787".parse().unwrap())
383    }
384
385    const ARRIVALS: [Arrival; 8] = [
386        Arrival::Page,
387        Arrival::Preflight,
388        Arrival::Socket,
389        Arrival::Door(Door::Transcribe),
390        Arrival::Door(Door::Enroll),
391        Arrival::Door(Door::Acp),
392        Arrival::Door(Door::Drop),
393        Arrival::Pairing,
394    ];
395
396    /// The five properties of `crates/wire/spec/Reception/Entry.lean`,
397    /// checked cell by cell over every input the model admits: each host,
398    /// origin and fetch-site class a request can present, at every route.
399    #[test]
400    fn every_entry_property_holds_over_every_input() {
401        let origins = loopback();
402        let hosts = [
403            (Some("127.0.0.1:8787"), true),
404            (Some("localhost:8787"), true),
405            (Some("evil.example:8787"), false),
406            (Some("0.0.0.0:8787"), false),
407            (None, false),
408        ];
409        let seen = [
410            (None, "absent"),
411            (Some("http://127.0.0.1:8787"), "listed"),
412            (Some("http://localhost:8787"), "listed"),
413            (Some("http://127.0.0.1:5173"), "foreign"),
414            (Some("null"), "foreign"),
415            (Some("https://127.0.0.1:8787"), "foreign"),
416        ];
417        let sites = [
418            (None, false),
419            (Some("same-origin"), false),
420            (Some("same-site"), true),
421            (Some("cross-site"), true),
422            (Some("none"), true),
423        ];
424        for (host, listed) in hosts {
425            for (origin, class) in seen {
426                for (sec_fetch_site, other) in sites {
427                    for arrival in ARRIVALS {
428                        let presented = Presented {
429                            host,
430                            origin,
431                            sec_fetch_site,
432                        };
433                        let entry = decide_entry(&origins, presented, arrival);
434                        let case = format!("{presented:?} at {arrival:?}: {entry:?}");
435                        if !listed {
436                            assert_eq!(entry, Entry::Refused(EntryRefusal::ForeignHost), "{case}");
437                            continue;
438                        }
439                        let entered = matches!(entry, Entry::Caller(_));
440                        if arrival != Arrival::Page && class == "foreign" {
441                            assert!(!entered, "{case}");
442                        }
443                        if arrival != Arrival::Page && other {
444                            assert!(!entered, "{case}");
445                        }
446                        if arrival == Arrival::Preflight {
447                            assert!(!entered, "{case}");
448                        }
449                        if arrival == Arrival::Pairing && entered {
450                            assert_eq!(entry, Entry::Caller(Caller::Browser), "{case}");
451                        }
452                        if arrival == Arrival::Page {
453                            assert_eq!(entry, Entry::Page, "{case}");
454                        }
455                    }
456                }
457            }
458        }
459    }
460
461    #[test]
462    fn a_listener_on_every_interface_is_opened_at_loopback_and_admits_its_own_host_only() {
463        let origins = ListenerOrigins::of("0.0.0.0:8787".parse().unwrap());
464        assert_eq!(origins.url(), "http://127.0.0.1:8787");
465        assert!(origins.admits_host("192.168.1.10:8787"));
466        assert!(!origins.admits_host("0.0.0.0:8787"));
467        assert!(!origins.admits_host("evil.example:8787"));
468        assert!(!origins.admits_host("192.168.1.10:9999"));
469        assert!(origins.admits_origin("http://192.168.1.10:8787", "192.168.1.10:8787"));
470        assert!(!origins.admits_origin("http://10.0.0.5:8787", "192.168.1.10:8787"));
471    }
472
473    /// Wire D56: a route may pass the browser's `Host` on or rewrite it
474    /// to the loopback port, and the page on the route sends the public
475    /// `https` origin; anything else is another site.
476    #[test]
477    fn a_routed_listener_admits_its_loopback_names_and_the_public_name_only() {
478        let origins = ListenerOrigins::routed(
479            "127.0.0.1:41000".parse().unwrap(),
480            "https://City.example.net:443/remote?x",
481        );
482        let at = |host, origin| {
483            let presented = Presented {
484                host: Some(host),
485                origin,
486                sec_fetch_site: Some("same-origin"),
487            };
488            decide_entry(&origins, presented, Arrival::Socket)
489        };
490        let public = Some("https://city.example.net");
491        let browser = Entry::Caller(Caller::Browser);
492        let seen = [
493            at("city.example.net", public),
494            at("127.0.0.1:41000", public),
495            at("localhost:41000", Some("http://localhost:41000")),
496            at("127.0.0.1:41000", None),
497            at("evil.example", public),
498            at("city.example.net:41000", public),
499            at("city.example.net", Some("https://evil.example")),
500            at("city.example.net", Some("http://city.example.net")),
501        ];
502        assert_eq!(
503            seen,
504            [
505                browser,
506                browser,
507                browser,
508                Entry::Caller(Caller::Native),
509                Entry::Refused(EntryRefusal::ForeignHost),
510                Entry::Refused(EntryRefusal::ForeignHost),
511                Entry::Refused(EntryRefusal::ForeignOrigin),
512                Entry::Refused(EntryRefusal::ForeignOrigin),
513            ]
514        );
515    }
516
517    #[test]
518    fn the_default_port_is_admitted_without_being_written() {
519        let origins = ListenerOrigins::of("127.0.0.1:80".parse().unwrap());
520        assert!(origins.admits_host("localhost"));
521        assert!(origins.admits_origin("http://localhost", "localhost"));
522    }
523}