1use std::net::{IpAddr, SocketAddr};
16
17use kernel::{AxCode, AxError};
18
19use super::Door;
20
21const SAME_ORIGIN: &str = "same-origin";
24
25const DEFAULT_HTTP_PORT: u16 = 80;
27
28const DEFAULT_HTTPS_PORT: &str = ":443";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ListenerOrigins {
35 hosts: Hosts,
36 url: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41enum Hosts {
42 Named {
45 names: Vec<String>,
46 origins: Vec<String>,
47 },
48 AnyAddress { port: u16, loopback: Vec<String> },
51}
52
53impl ListenerOrigins {
54 #[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 #[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 #[must_use]
109 pub fn url(&self) -> &str {
110 &self.url
111 }
112
113 #[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 #[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 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
155fn 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
168fn 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
178fn literal(ip: IpAddr) -> String {
180 match ip {
181 IpAddr::V4(v4) => v4.to_string(),
182 IpAddr::V6(v6) => format!("[{v6}]"),
183 }
184}
185
186fn 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#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct PageHeaders {
207 lines: Vec<(&'static str, String)>,
208}
209
210impl PageHeaders {
211 #[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 #[must_use]
237 pub fn lines(&self) -> &[(&'static str, String)] {
238 &self.lines
239 }
240}
241
242#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum Arrival {
254 Page,
256 Preflight,
258 Socket,
260 Door(Door),
262 Pairing,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Caller {
269 Native,
271 Browser,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Entry {
278 Page,
281 Caller(Caller),
283 Refused(EntryRefusal),
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum EntryRefusal {
290 ForeignHost,
291 Preflight,
292 ForeignOrigin,
293 CrossSite,
294 NativePairing,
295}
296
297impl EntryRefusal {
298 #[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#[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 #[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 #[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}