Skip to main content

custom_protocol_http/
origin.rs

1//! Origin normalisation: one origin for the server, whatever the platform gave
2//! the webview.
3//!
4//! A custom protocol is reached as `scheme://localhost` on macOS, iOS and
5//! Linux, and as `http://scheme.localhost` on Windows and Android, where the
6//! webview cannot register a non-standard scheme. A server that saw those
7//! differences would have to treat its own address as platform-conditional,
8//! and so would every application on top of it.
9//!
10//! Instead the server always sees `https://scheme.localhost`. `https` because a
11//! server that gates anything on its own scheme should behave here as it does
12//! in production, and this transport is more isolated than the TLS it stands
13//! in for. `.localhost` is reserved by RFC 6761 and can never resolve off the
14//! machine, so a canonical URL that escapes into a real fetch fails closed
15//! instead of reaching a host somebody registered.
16
17use http::{HeaderMap, HeaderName, HeaderValue, Request, Uri, header, uri};
18
19/// The URL shape a webview gives a custom protocol.
20///
21/// The only place in this crate that names an operating system. A new platform
22/// adds a variant here and nowhere else.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Platform {
25    /// `<scheme>://localhost/...` - macOS, iOS, Linux.
26    Scheme,
27
28    /// `http://<scheme>.localhost/...` - Windows and Android, where the
29    /// webview rewrites custom schemes onto http.
30    HttpSubdomain,
31
32    /// `https://<scheme>.localhost/...` - as [`HttpSubdomain`], for a webview
33    /// configured to use https for that rewrite.
34    ///
35    /// [`HttpSubdomain`]: Platform::HttpSubdomain
36    HttpsSubdomain,
37}
38
39impl Platform {
40    /// The shape this build's webview uses, before any https override.
41    #[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/// A `scheme://host` origin, compared without regard to ASCII case.
55///
56/// `http`'s own scheme and authority, which compare that way already and drop
57/// straight into a `Uri`. Not `url::Origin`: per the URL standard a non-special
58/// scheme has an *opaque* origin, and `url` gives every opaque origin a fresh
59/// counter value, so `topcoat://localhost` does not compare equal to itself.
60#[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    /// The scheme half.
72    #[must_use]
73    pub const fn scheme(&self) -> &uri::Scheme {
74        &self.scheme
75    }
76
77    /// The authority half, which for every origin here is a bare host.
78    #[must_use]
79    pub const fn authority(&self) -> &uri::Authority {
80        &self.authority
81    }
82
83    /// Whether `url` is a URL within this origin.
84    ///
85    /// Parsed and not prefix-matched: `topcoat://localhost.evil.example` begins
86    /// with `topcoat://localhost` and belongs to somebody else. Anything naming
87    /// no origin - a relative reference, a `data:` document - is not in one.
88    #[must_use]
89    pub fn covers(&self, url: &str) -> bool {
90        url.parse::<Uri>().is_ok_and(|uri| self.holds(&uri))
91    }
92
93    /// The same question, already parsed.
94    fn holds(&self, uri: &Uri) -> bool {
95        uri.scheme() == Some(&self.scheme) && uri.authority() == Some(&self.authority)
96    }
97
98    /// This origin with `path_and_query` on the end.
99    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
108/// `scheme://host`, as an `Origin` header spells one: no trailing slash.
109impl 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/// A protocol scheme that could not be turned into an origin pair.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum OriginError {
118    /// The scheme name is not usable as both a URL scheme and a DNS label.
119    ///
120    /// On Windows and Android the scheme becomes the first label of
121    /// `<scheme>.localhost`, so it is held to the stricter of the two rules:
122    /// a leading ASCII letter followed by letters, digits, and hyphens, within
123    /// the 63 octets a DNS label may be.
124    InvalidScheme {
125        /// The scheme as given.
126        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/// Why a request was refused before the server saw it.
146///
147/// Exhaustive on purpose: a new way to refuse should make the shell reconsider
148/// what it reports, at compile time.
149///
150/// A refusal becomes a `403` body and a log line, so the fields are an address
151/// and nothing else: there is none a path or a query could arrive in.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum Denial {
154    /// The URL was not for the origin this protocol serves.
155    ///
156    /// Nothing routable should produce this; it means the webview handed us a
157    /// request meant for somewhere else.
158    ForeignAuthority {
159        /// The scheme, where there was one; authority-form names none.
160        scheme: Option<uri::Scheme>,
161        /// The authority the request named.
162        authority: uri::Authority,
163    },
164    /// The canonical URL could not be rebuilt from its validated parts.
165    ///
166    /// Unreachable in practice, and refusing beats unwrapping.
167    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/// The pair of origins a custom protocol lives between, and the sole entry
192/// point for turning a webview request into one the server may serve.
193#[derive(Debug, Clone)]
194pub struct Origins {
195    canonical: Origin,
196    platform: Origin,
197    /// The `Host` every admitted request leaves with, built once.
198    host: HeaderValue,
199    /// The canonical origin as an `Origin` header, likewise.
200    origin: HeaderValue,
201}
202
203impl Origins {
204    /// Builds the origin pair for a protocol named `scheme` on `platform`.
205    ///
206    /// # Errors
207    ///
208    /// [`OriginError::InvalidScheme`] if the name cannot serve as both a URL
209    /// scheme and a DNS label.
210    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    /// The pair, or [`None`] for a name that cannot make one.
217    ///
218    /// One `Option` and not four error paths: every step below rejects the same
219    /// mistake, and asking anyway is what keeps this free of an unwrap.
220    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    /// The origin the server sees, on every platform.
243    #[must_use]
244    pub const fn canonical(&self) -> &Origin {
245        &self.canonical
246    }
247
248    /// The origin the webview speaks on this platform.
249    #[must_use]
250    pub const fn platform(&self) -> &Origin {
251        &self.platform
252    }
253
254    /// Whether `url` names the origin this protocol serves.
255    ///
256    /// The shell uses this to refuse, at startup, an application whose public
257    /// base URL is our own canonical origin: absolute URLs built from it would
258    /// escape the protocol and be fetched for real, which is a broken image
259    /// long after the mistake rather than an error at the moment of it.
260    #[must_use]
261    pub fn collides_with(&self, url: &str) -> bool {
262        self.canonical.covers(url) || self.platform.covers(url)
263    }
264
265    /// Admits a request and rewrites it into the canonical origin.
266    ///
267    /// Admission is only the question of address: a request naming somebody
268    /// else's origin is refused, and everything else is rewritten. Deciding
269    /// whether a request may *act* is the server's job, and this crate attaches
270    /// no credential that would undermine the answer.
271    ///
272    /// `Origin` and `Referer` are moved onto the canonical origin only when
273    /// they were ours to begin with. A foreign value passes through untouched,
274    /// so a server running its own origin checks still sees a stranger as a
275    /// stranger.
276    ///
277    /// `Host` is set, because no webview reliably sends one and a server
278    /// comparing `Origin` against it needs both. `Accept-Encoding` is dropped,
279    /// because nothing here is on a wire and WKWebView will not decode what it
280    /// is handed anyway. Hop-by-hop headers
281    /// go too, including the ones a `Connection` names - except `Host`,
282    /// `Origin` and `Referer`, which a client does not get to delete.
283    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    /// Rejects a request addressed to anything but the origin we serve.
304    ///
305    /// A URI naming no authority is a relative request, which cannot name a
306    /// foreign origin and so is ours. There is no third case: [`Uri`] has no
307    /// shape carrying a scheme without one.
308    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        // An `Origin` carries no path, so ours is replaced outright where a
326        // `Referer` has to be rebuilt around one.
327        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    /// Whether a header value is a URL on the origin the webview speaks.
342    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    /// Moves a URL from the platform origin onto the canonical one, or [`None`]
349    /// if it was never ours to move.
350    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
360/// The host reserved by RFC 6761, which every origin here is under.
361const LOCALHOST: &str = "localhost";
362
363/// Credentials a client attaches by itself, without the document asking.
364///
365/// `Authorization` is deliberately not here: a document sets it explicitly and
366/// a cross-origin attacker cannot, so it is the one credential CSRF already
367/// cannot forge.
368const AMBIENT_AUTHORITY: &[HeaderName] = &[header::COOKIE];
369
370/// Headers that describe a connection rather than a message. A custom protocol
371/// has no connection to describe.
372const 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
382/// Headers a `Connection` may not name away.
383///
384/// Dropping one is normally fail-safe; for these three it is the opposite,
385/// since a request arriving with no `Origin` is one a server passes.
386const DECIDES_ADMISSION: &[HeaderName] = &[header::HOST, header::ORIGIN, header::REFERER];
387
388/// Removes the headers that describe a connection rather than a message.
389///
390/// `Connection` names further ones - RFC 7230 6.1 - so what it names goes with
391/// it, less anything in [`DECIDES_ADMISSION`].
392fn 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/// A request that has been admitted and rewritten into the canonical origin.
408///
409/// Constructible only by [`Origins::accept`], so the rewrite cannot be skipped
410/// on the way to the server.
411#[derive(Debug)]
412pub struct CanonicalRequest<B> {
413    request: Request<B>,
414}
415
416impl<B> CanonicalRequest<B> {
417    /// Borrows the underlying request.
418    #[must_use]
419    pub const fn get_ref(&self) -> &Request<B> {
420        &self.request
421    }
422
423    /// Takes the underlying request, to hand to the server.
424    #[must_use]
425    pub fn into_inner(self) -> Request<B> {
426        self.request
427    }
428}
429
430/// What to do with a request the webview delivered.
431#[derive(Debug)]
432#[must_use]
433pub enum Outcome<B> {
434    /// Serve it: hand the inner request to the server.
435    Serve(CanonicalRequest<B>),
436    /// Refuse it, without troubling the server.
437    Deny(Denial),
438}
439
440/// The octets a DNS label may be, per RFC 1035.
441///
442/// Shorter than the 64 `http` allows a scheme, so it is the rule both are held
443/// to.
444const MAX_LABEL: usize = 63;
445
446/// Whether `scheme` works as both a URL scheme and a DNS label.
447fn 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    /// A refusal is a `403` body and a log line, and a path is not an address.
563    #[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    /// An `Origin` is an origin and a `Referer` is a URL, so only one of them
622    /// comes back with a path.
623    #[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    /// RFC 7230 6.1: a `Connection` names further headers as this hop's only.
686    #[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    /// The one a client would reach for.
705    ///
706    /// Stripping `Origin` is fail-open - a request carrying none is one a
707    /// server passes - so it is not on offer.
708    #[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        // Names no origin at all, so it is not within this one.
781        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}