1use http::{HeaderMap, HeaderName, HeaderValue, Request, Uri, header, uri};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Platform {
25 Scheme,
27
28 HttpSubdomain,
31
32 HttpsSubdomain,
37}
38
39impl Platform {
40 #[must_use]
42 pub const fn current() -> Platform {
43 #[cfg(any(target_os = "windows", target_os = "android"))]
44 {
45 Platform::HttpSubdomain
46 }
47 #[cfg(not(any(target_os = "windows", target_os = "android")))]
48 {
49 Platform::Scheme
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Origin {
62 scheme: uri::Scheme,
63 authority: uri::Authority,
64}
65
66impl Origin {
67 const fn new(scheme: uri::Scheme, authority: uri::Authority) -> Origin {
68 Origin { scheme, authority }
69 }
70
71 #[must_use]
73 pub const fn scheme(&self) -> &uri::Scheme {
74 &self.scheme
75 }
76
77 #[must_use]
79 pub const fn authority(&self) -> &uri::Authority {
80 &self.authority
81 }
82
83 #[must_use]
89 pub fn covers(&self, url: &str) -> bool {
90 url.parse::<Uri>().is_ok_and(|uri| self.holds(&uri))
91 }
92
93 fn holds(&self, uri: &Uri) -> bool {
95 uri.scheme() == Some(&self.scheme) && uri.authority() == Some(&self.authority)
96 }
97
98 fn join(&self, path_and_query: uri::PathAndQuery) -> Result<Uri, http::Error> {
100 Uri::builder()
101 .scheme(self.scheme.clone())
102 .authority(self.authority.clone())
103 .path_and_query(path_and_query)
104 .build()
105 }
106}
107
108impl core::fmt::Display for Origin {
110 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
111 write!(f, "{}://{}", self.scheme, self.authority)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum OriginError {
118 InvalidScheme {
125 scheme: String,
127 },
128}
129
130impl core::fmt::Display for OriginError {
131 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
132 match self {
133 OriginError::InvalidScheme { scheme } => write!(
134 f,
135 "`{scheme}` is not a usable protocol scheme: it must start with an ASCII letter, \
136 contain only ASCII letters, digits, and hyphens, and be at most {MAX_LABEL} \
137 characters"
138 ),
139 }
140 }
141}
142
143impl core::error::Error for OriginError {}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum Denial {
154 ForeignAuthority {
159 scheme: Option<uri::Scheme>,
161 authority: uri::Authority,
163 },
164 MalformedUri,
168}
169
170impl core::fmt::Display for Denial {
171 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
172 match self {
173 Denial::ForeignAuthority {
174 scheme: Some(scheme),
175 authority,
176 } => write!(
177 f,
178 "`{scheme}://{authority}` is not the origin this protocol serves"
179 ),
180 Denial::ForeignAuthority {
181 scheme: None,
182 authority,
183 } => write!(f, "`{authority}` is not the origin this protocol serves"),
184 Denial::MalformedUri => f.write_str("the canonical URL could not be built"),
185 }
186 }
187}
188
189impl core::error::Error for Denial {}
190
191#[derive(Debug, Clone)]
194pub struct Origins {
195 canonical: Origin,
196 platform: Origin,
197 host: HeaderValue,
199 origin: HeaderValue,
201}
202
203impl Origins {
204 pub fn new(scheme: &str, platform: Platform) -> Result<Origins, OriginError> {
211 Origins::build(scheme, platform).ok_or_else(|| OriginError::InvalidScheme {
212 scheme: scheme.to_owned(),
213 })
214 }
215
216 fn build(scheme: &str, platform: Platform) -> Option<Origins> {
221 if !is_usable_scheme(scheme) {
222 return None;
223 }
224 let scheme = uri::Scheme::try_from(scheme.to_ascii_lowercase().as_str()).ok()?;
225 let host = uri::Authority::try_from(format!("{scheme}.{LOCALHOST}").as_str()).ok()?;
226
227 let canonical = Origin::new(uri::Scheme::HTTPS, host.clone());
228 let platform = match platform {
229 Platform::Scheme => Origin::new(scheme, uri::Authority::from_static(LOCALHOST)),
230 Platform::HttpSubdomain => Origin::new(uri::Scheme::HTTP, host.clone()),
231 Platform::HttpsSubdomain => canonical.clone(),
232 };
233
234 Some(Origins {
235 host: HeaderValue::try_from(host.as_str()).ok()?,
236 origin: HeaderValue::try_from(canonical.to_string()).ok()?,
237 canonical,
238 platform,
239 })
240 }
241
242 #[must_use]
244 pub const fn canonical(&self) -> &Origin {
245 &self.canonical
246 }
247
248 #[must_use]
250 pub const fn platform(&self) -> &Origin {
251 &self.platform
252 }
253
254 #[must_use]
261 pub fn collides_with(&self, url: &str) -> bool {
262 self.canonical.covers(url) || self.platform.covers(url)
263 }
264
265 pub fn accept<B>(&self, mut request: Request<B>) -> Outcome<B> {
284 if let Err(denial) = self.check_authority(request.uri()) {
285 return Outcome::Deny(denial);
286 }
287
288 let path_and_query = request
289 .uri()
290 .path_and_query()
291 .cloned()
292 .unwrap_or_else(|| uri::PathAndQuery::from_static("/"));
293 let Ok(rewritten) = self.canonical.join(path_and_query) else {
294 return Outcome::Deny(Denial::MalformedUri);
295 };
296 crate::trace::rewrote_origin(&self.platform, rewritten.path());
297 *request.uri_mut() = rewritten;
298
299 self.rewrite_headers(request.headers_mut());
300 Outcome::Serve(CanonicalRequest { request })
301 }
302
303 fn check_authority(&self, uri: &Uri) -> Result<(), Denial> {
309 let Some(authority) = uri.authority() else {
310 return Ok(());
311 };
312 if self.platform.holds(uri) {
313 Ok(())
314 } else {
315 Err(Denial::ForeignAuthority {
316 scheme: uri.scheme().cloned(),
317 authority: authority.clone(),
318 })
319 }
320 }
321
322 fn rewrite_headers(&self, headers: &mut HeaderMap) {
323 headers.insert(header::HOST, self.host.clone());
324
325 if self.is_ours(headers.get(header::ORIGIN)) {
328 headers.insert(header::ORIGIN, self.origin.clone());
329 }
330 if let Some(rebased) = self.rebase(headers.get(header::REFERER)) {
331 headers.insert(header::REFERER, rebased);
332 }
333
334 headers.remove(header::ACCEPT_ENCODING);
335 for name in AMBIENT_AUTHORITY {
336 headers.remove(name);
337 }
338 remove_hop_by_hop(headers);
339 }
340
341 fn is_ours(&self, value: Option<&HeaderValue>) -> bool {
343 value
344 .and_then(|value| value.to_str().ok())
345 .is_some_and(|url| self.platform.covers(url))
346 }
347
348 fn rebase(&self, value: Option<&HeaderValue>) -> Option<HeaderValue> {
351 let url: Uri = value?.to_str().ok()?.parse().ok()?;
352 if !self.platform.holds(&url) {
353 return None;
354 }
355 let rebased = self.canonical.join(url.path_and_query()?.clone()).ok()?;
356 HeaderValue::try_from(rebased.to_string()).ok()
357 }
358}
359
360const LOCALHOST: &str = "localhost";
362
363const AMBIENT_AUTHORITY: &[HeaderName] = &[header::COOKIE];
369
370const HOP_BY_HOP: &[HeaderName] = &[
373 header::CONNECTION,
374 header::PROXY_AUTHENTICATE,
375 header::PROXY_AUTHORIZATION,
376 header::TE,
377 header::TRAILER,
378 header::TRANSFER_ENCODING,
379 header::UPGRADE,
380];
381
382const DECIDES_ADMISSION: &[HeaderName] = &[header::HOST, header::ORIGIN, header::REFERER];
387
388fn remove_hop_by_hop(headers: &mut HeaderMap) {
393 let named: Vec<HeaderName> = headers
394 .get_all(header::CONNECTION)
395 .iter()
396 .filter_map(|value| value.to_str().ok())
397 .flat_map(|value| value.split(','))
398 .filter_map(|token| HeaderName::try_from(token.trim()).ok())
399 .filter(|name| !DECIDES_ADMISSION.contains(name))
400 .collect();
401
402 for name in named.iter().chain(HOP_BY_HOP) {
403 headers.remove(name);
404 }
405}
406
407#[derive(Debug)]
412pub struct CanonicalRequest<B> {
413 request: Request<B>,
414}
415
416impl<B> CanonicalRequest<B> {
417 #[must_use]
419 pub const fn get_ref(&self) -> &Request<B> {
420 &self.request
421 }
422
423 #[must_use]
425 pub fn into_inner(self) -> Request<B> {
426 self.request
427 }
428}
429
430#[derive(Debug)]
432#[must_use]
433pub enum Outcome<B> {
434 Serve(CanonicalRequest<B>),
436 Deny(Denial),
438}
439
440const MAX_LABEL: usize = 63;
445
446fn is_usable_scheme(scheme: &str) -> bool {
448 let mut characters = scheme.chars();
449 scheme.len() <= MAX_LABEL
450 && characters
451 .next()
452 .is_some_and(|first| first.is_ascii_alphabetic())
453 && characters.all(|character| character.is_ascii_alphanumeric() || character == '-')
454 && !scheme.ends_with('-')
455}
456
457#[cfg(test)]
458mod tests {
459 use http::Method;
460
461 use super::*;
462
463 fn origins(platform: Platform) -> Origins {
464 Origins::new("topcoat", platform).expect("`topcoat` is a valid scheme")
465 }
466
467 fn get(uri: &str) -> Request<()> {
468 Request::builder()
469 .uri(uri)
470 .body(())
471 .expect("a valid request")
472 }
473
474 #[test]
475 fn every_platform_presents_the_same_canonical_origin() {
476 for platform in [
477 Platform::Scheme,
478 Platform::HttpSubdomain,
479 Platform::HttpsSubdomain,
480 ] {
481 let canonical = origins(platform);
482 let canonical = canonical.canonical();
483 assert_eq!(canonical.to_string(), "https://topcoat.localhost");
484 assert_eq!(canonical.scheme(), &uri::Scheme::HTTPS);
485 assert_eq!(canonical.authority(), "topcoat.localhost");
486 }
487 }
488
489 #[test]
490 fn platform_origins_match_what_each_webview_speaks() {
491 assert_eq!(
492 origins(Platform::Scheme).platform().to_string(),
493 "topcoat://localhost"
494 );
495 assert_eq!(
496 origins(Platform::HttpSubdomain).platform().to_string(),
497 "http://topcoat.localhost"
498 );
499 let https = origins(Platform::HttpsSubdomain);
500 assert_eq!(https.platform(), https.canonical());
501 }
502
503 #[test]
504 fn a_scheme_that_is_not_a_dns_label_is_refused() {
505 let too_long = "t".repeat(MAX_LABEL + 1);
506 for scheme in [
507 "", "1topcoat", "top coat", "top_coat", "top.coat", "topcoat-", &too_long,
508 ] {
509 assert_eq!(
510 Origins::new(scheme, Platform::Scheme).err(),
511 Some(OriginError::InvalidScheme {
512 scheme: scheme.to_owned()
513 }),
514 "{scheme:?} was accepted"
515 );
516 }
517 assert!(Origins::new("top-coat2", Platform::Scheme).is_ok());
518 assert!(Origins::new(&"t".repeat(MAX_LABEL), Platform::Scheme).is_ok());
519 }
520
521 #[test]
522 fn the_path_and_query_survive_the_rewrite() {
523 let Outcome::Serve(request) =
524 origins(Platform::Scheme).accept(get("topcoat://localhost/a/b?c=d&e=%20f"))
525 else {
526 panic!("a GET from our own origin should be served");
527 };
528 assert_eq!(
529 request.get_ref().uri().to_string(),
530 "https://topcoat.localhost/a/b?c=d&e=%20f"
531 );
532 }
533
534 #[test]
535 fn the_windows_shape_rewrites_to_the_same_canonical_url() {
536 let Outcome::Serve(request) =
537 origins(Platform::HttpSubdomain).accept(get("http://topcoat.localhost/a?b=c"))
538 else {
539 panic!("a GET from our own origin should be served");
540 };
541 assert_eq!(
542 request.get_ref().uri().to_string(),
543 "https://topcoat.localhost/a?b=c"
544 );
545 }
546
547 #[test]
548 fn a_request_for_a_foreign_authority_is_refused() {
549 let outcome = origins(Platform::Scheme).accept(get("https://evil.example/a"));
550 let Outcome::Deny(denial) = outcome else {
551 panic!("somebody else's origin should be refused: {outcome:?}");
552 };
553 assert_eq!(
554 denial,
555 Denial::ForeignAuthority {
556 scheme: Some(uri::Scheme::HTTPS),
557 authority: uri::Authority::from_static("evil.example"),
558 }
559 );
560 }
561
562 #[test]
564 fn a_refusal_names_an_origin_and_nothing_further() {
565 let outcome =
566 origins(Platform::Scheme).accept(get("https://evil.example/inbox?token=s3cret"));
567 let Outcome::Deny(denial) = outcome else {
568 panic!("somebody else's origin should be refused: {outcome:?}");
569 };
570 let said = denial.to_string();
571 assert!(said.contains("evil.example"), "{said}");
572 assert!(!said.contains("inbox"), "the path was reported: {said}");
573 assert!(!said.contains("s3cret"), "the query was reported: {said}");
574 }
575
576 #[test]
577 fn a_relative_request_is_ours_by_construction() {
578 let outcome = origins(Platform::Scheme).accept(get("/a/b"));
579 assert!(matches!(outcome, Outcome::Serve(_)), "{outcome:?}");
580 }
581
582 #[test]
583 fn host_is_supplied_because_no_webview_reliably_sends_one() {
584 let Outcome::Serve(request) = origins(Platform::Scheme).accept(get("topcoat://localhost/"))
585 else {
586 panic!("a GET from our own origin should be served");
587 };
588 assert_eq!(
589 request
590 .get_ref()
591 .headers()
592 .get(header::HOST)
593 .map(|h| h.to_str().unwrap_or_default()),
594 Some("topcoat.localhost")
595 );
596 }
597
598 #[test]
599 fn our_own_origin_and_referer_are_rebased() {
600 let request = Request::builder()
601 .uri("topcoat://localhost/submit")
602 .method(Method::POST)
603 .header(header::ORIGIN, "topcoat://localhost")
604 .header(header::REFERER, "topcoat://localhost/form?x=1")
605 .body(())
606 .expect("a valid request");
607 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
608 panic!("a POST from our own origin should be served");
609 };
610 let headers = request.get_ref().headers();
611 assert_eq!(
612 headers.get(header::ORIGIN).and_then(|h| h.to_str().ok()),
613 Some("https://topcoat.localhost")
614 );
615 assert_eq!(
616 headers.get(header::REFERER).and_then(|h| h.to_str().ok()),
617 Some("https://topcoat.localhost/form?x=1")
618 );
619 }
620
621 #[test]
624 fn a_rebased_referer_is_a_url_and_a_rebased_origin_is_not() {
625 let request = Request::builder()
626 .uri("topcoat://localhost/submit")
627 .method(Method::POST)
628 .header(header::ORIGIN, "topcoat://localhost")
629 .header(header::REFERER, "topcoat://localhost")
630 .body(())
631 .expect("a valid request");
632 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
633 panic!("a POST from our own origin should be served");
634 };
635 let headers = request.get_ref().headers();
636 assert_eq!(
637 headers.get(header::ORIGIN),
638 Some(&HeaderValue::from_static("https://topcoat.localhost"))
639 );
640 assert_eq!(
641 headers.get(header::REFERER),
642 Some(&HeaderValue::from_static("https://topcoat.localhost/")),
643 "a URL with an empty path is spelled with the slash"
644 );
645 }
646
647 #[test]
648 fn a_foreign_origin_is_left_alone_rather_than_laundered() {
649 let request = Request::builder()
650 .uri("topcoat://localhost/read")
651 .header(header::ORIGIN, "https://evil.example")
652 .body(())
653 .expect("a valid request");
654 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
655 panic!("a GET is always served");
656 };
657 assert_eq!(
658 request
659 .get_ref()
660 .headers()
661 .get(header::ORIGIN)
662 .and_then(|h| h.to_str().ok()),
663 Some("https://evil.example")
664 );
665 }
666
667 #[test]
668 fn accept_encoding_and_hop_by_hop_headers_are_dropped() {
669 let request = Request::builder()
670 .uri("topcoat://localhost/")
671 .header(header::ACCEPT_ENCODING, "gzip, br")
672 .header(header::CONNECTION, "keep-alive")
673 .header(header::TRANSFER_ENCODING, "chunked")
674 .body(())
675 .expect("a valid request");
676 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
677 panic!("a GET is always served");
678 };
679 let headers = request.get_ref().headers();
680 assert!(headers.get(header::ACCEPT_ENCODING).is_none());
681 assert!(headers.get(header::CONNECTION).is_none());
682 assert!(headers.get(header::TRANSFER_ENCODING).is_none());
683 }
684
685 #[test]
687 fn a_header_connection_names_goes_with_it() {
688 let request = Request::builder()
689 .uri("topcoat://localhost/")
690 .header(header::CONNECTION, "keep-alive, X-Internal-Trace")
691 .header("x-internal-trace", "abc123")
692 .header("x-end-to-end", "kept")
693 .body(())
694 .expect("a valid request");
695 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
696 panic!("a GET is always served");
697 };
698 let headers = request.get_ref().headers();
699 assert!(headers.get("x-internal-trace").is_none());
700 assert!(headers.get(header::CONNECTION).is_none());
701 assert!(headers.get("x-end-to-end").is_some(), "took an unnamed one");
702 }
703
704 #[test]
709 fn a_connection_cannot_name_away_the_evidence_against_it() {
710 let request = Request::builder()
711 .uri("topcoat://localhost/submit")
712 .method(Method::POST)
713 .header(header::CONNECTION, "origin, referer, host")
714 .header(header::ORIGIN, "https://evil.example")
715 .body(())
716 .expect("a valid request");
717 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
718 panic!("a POST is served; whether it may act is the server's call");
719 };
720 let headers = request.get_ref().headers();
721 assert_eq!(
722 headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()),
723 Some("https://evil.example"),
724 "a stranger deleted its own `Origin` and arrived looking local"
725 );
726 assert!(headers.get(header::HOST).is_some(), "nothing to compare to");
727 }
728
729 #[test]
730 fn an_inbound_cookie_never_reaches_the_server() {
731 let request = Request::builder()
732 .uri("topcoat://localhost/")
733 .header(header::COOKIE, "__Host-session=t0ken; other=1")
734 .body(())
735 .expect("a valid request");
736 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
737 panic!("a GET is always served");
738 };
739 assert!(
740 request.get_ref().headers().get(header::COOKIE).is_none(),
741 "a credential arrived with neither `Origin` nor `Sec-Fetch-Site` to vouch for it"
742 );
743 }
744
745 #[test]
746 fn an_authorization_header_is_left_for_the_application() {
747 let request = Request::builder()
748 .uri("topcoat://localhost/")
749 .header(header::AUTHORIZATION, "Bearer t0ken")
750 .body(())
751 .expect("a valid request");
752 let Outcome::Serve(request) = origins(Platform::Scheme).accept(request) else {
753 panic!("a GET is always served");
754 };
755 assert_eq!(
756 request
757 .get_ref()
758 .headers()
759 .get(header::AUTHORIZATION)
760 .and_then(|value| value.to_str().ok()),
761 Some("Bearer t0ken"),
762 "an explicit credential is not ambient and is not ours to drop"
763 );
764 }
765
766 #[test]
767 fn covers_compares_the_whole_origin_and_only_the_origin() {
768 let origins = origins(Platform::Scheme);
769 let origin = origins.platform();
770 assert!(origin.covers("topcoat://localhost"));
771 assert!(origin.covers("topcoat://localhost/"));
772 assert!(origin.covers("topcoat://localhost/a?b#c"));
773 assert!(origin.covers("TOPCOAT://LOCALHOST/a"));
774 assert!(!origin.covers("topcoat://localhost.evil.example/"));
775 assert!(!origin.covers("topcoat://localhostx"));
776 assert!(!origin.covers("topcoat://localhost:8080/"));
777 assert!(!origin.covers("topcoat://user@localhost/"));
778 assert!(!origin.covers("https://localhost/"));
779 assert!(!origin.covers("topcoat://"));
780 assert!(!origin.covers("/a/b"));
782 assert!(!origin.covers("localhost"));
783 assert!(!origin.covers("data:text/html,<p>hi"));
784 assert!(!origin.covers("null"));
785 }
786
787 #[test]
788 fn collides_with_catches_an_application_base_url_pointed_at_us() {
789 let origins = origins(Platform::Scheme);
790 assert!(origins.collides_with("https://topcoat.localhost"));
791 assert!(origins.collides_with("https://topcoat.localhost/app"));
792 assert!(origins.collides_with("topcoat://localhost"));
793 assert!(!origins.collides_with("https://example.com"));
794 assert!(!origins.collides_with("https://topcoat.localhost.evil.example"));
795 }
796}