Skip to main content

cratefield_core/
origin.rs

1//! One `origin_of`, so a Content-Security-Policy and a VAPID `aud` agree
2//! on what an origin is (issue #215).
3//!
4//! There were two. The strict one — RFC 6454 via `url::Url::origin()` —
5//! minted the `aud` of a VAPID token, where getting it wrong earns a `401`
6//! from every push service that checks. The loose one split on `"://"` and
7//! took everything up to the first `/`, and it was the one deciding which
8//! origins a page may load stylesheets from.
9//!
10//! That asymmetry is the wrong way round. A CSP builder is exactly the
11//! place where "close enough to an origin" stops being close enough:
12//!
13//! - any scheme before `://` was accepted, so a configured `theme_css` of
14//!   `javascript://x/` produced a `style-src` entry a URL parser would
15//!   have refused;
16//! - userinfo was kept, so `https://a@evil.test/x` became
17//!   `https://a@evil.test` — not an origin, and not what CSP matches;
18//! - the port was not normalised, so `https://x:443` and `https://x` were
19//!   two different entries for the same origin;
20//! - the host was neither lowercased nor punycoded, so one differing only
21//!   in case or encoding silently failed to match at render time.
22//!
23//! None of those was reachable from request input — `theme_css` is venture
24//! configuration — which is why this was an issue rather than an incident.
25
26/// Why a string is not an origin this harness will name.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum OriginError {
29    /// Not a URL at all, with the parser's reason.
30    NotAUrl(String),
31    /// A scheme other than `http` or `https`.
32    Scheme(String),
33    /// A URL whose origin is opaque (`data:`, `blob:`, a `file:` URL):
34    /// RFC 6454 gives it no tuple, and neither CSP nor a VAPID `aud` can
35    /// name one.
36    Opaque,
37}
38
39impl std::fmt::Display for OriginError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::NotAUrl(why) => write!(f, "{why}"),
43            Self::Scheme(scheme) => write!(f, "scheme {scheme:?} is not http or https"),
44            Self::Opaque => f.write_str("opaque origin"),
45        }
46    }
47}
48
49impl std::error::Error for OriginError {}
50
51/// The RFC 6454 origin of `url`: scheme, host and non-default port, ASCII
52/// serialized.
53///
54/// Never the path, the query, the fragment or the userinfo. The default
55/// port is dropped and a non-default one kept, the host is lowercased and
56/// punycoded, and an opaque origin is refused rather than guessed at —
57/// all of which `url::Url::origin` does, and none of which string
58/// splitting does.
59///
60/// `http` is allowed alongside `https` because a self-hosted UnifiedPush
61/// distributor is routinely reached over plain HTTP on a private network,
62/// and the Web Push adapter exists partly to serve that case. It is an
63/// allowance, not an endorsement; `crates/adapter-webpush/src/vapid.rs`
64/// carries the full argument for what travels in cleartext when it is
65/// taken.
66///
67/// # Errors
68///
69/// [`OriginError`], one variant per way the string is not an origin.
70pub fn origin_of(url: &str) -> Result<String, OriginError> {
71    let parsed = url::Url::parse(url).map_err(|err| OriginError::NotAUrl(err.to_string()))?;
72    if !matches!(parsed.scheme(), "http" | "https") {
73        return Err(OriginError::Scheme(parsed.scheme().to_owned()));
74    }
75    let origin = parsed.origin();
76    if !origin.is_tuple() {
77        return Err(OriginError::Opaque);
78    }
79    Ok(origin.ascii_serialization())
80}
81
82#[cfg(test)]
83mod tests {
84    use super::{OriginError, origin_of};
85
86    #[test]
87    fn an_ordinary_url_keeps_its_origin_and_loses_everything_else() {
88        assert_eq!(
89            origin_of("https://cdn.example/theme/dark.css?v=2#top").unwrap(),
90            "https://cdn.example"
91        );
92    }
93
94    #[test]
95    fn a_non_default_port_stays_and_a_default_one_goes() {
96        // `https://x:443` and `https://x` are one origin; emitting both as
97        // separate `style-src` entries is how a CSP quietly stops matching.
98        assert_eq!(
99            origin_of("https://x.example:443/a").unwrap(),
100            "https://x.example"
101        );
102        assert_eq!(
103            origin_of("http://x.example:80/a").unwrap(),
104            "http://x.example"
105        );
106        assert_eq!(
107            origin_of("https://x.example:8443/a").unwrap(),
108            "https://x.example:8443"
109        );
110    }
111
112    #[test]
113    fn userinfo_never_reaches_the_output() {
114        // The loose version produced `https://a@evil.test`, which is not an
115        // origin and is not what a browser matches a stylesheet against.
116        assert_eq!(
117            origin_of("https://a:b@evil.test/x.css").unwrap(),
118            "https://evil.test"
119        );
120    }
121
122    #[test]
123    fn the_host_is_lowercased_and_punycoded() {
124        assert_eq!(
125            origin_of("https://EXAMPLE.TEST/a").unwrap(),
126            "https://example.test"
127        );
128        assert_eq!(
129            origin_of("https://münchen.test/a").unwrap(),
130            "https://xn--mnchen-3ya.test"
131        );
132    }
133
134    #[test]
135    fn a_scheme_that_is_not_http_is_refused() {
136        // The one that mattered: the loose version accepted anything before
137        // `://` and spliced it into `style-src`.
138        assert_eq!(
139            origin_of("javascript://x/"),
140            Err(OriginError::Scheme("javascript".to_owned()))
141        );
142        assert!(matches!(
143            origin_of("file:///etc/passwd"),
144            Err(OriginError::Scheme(_))
145        ));
146    }
147
148    #[test]
149    fn an_opaque_origin_is_refused_rather_than_named() {
150        assert!(matches!(
151            origin_of("data:text/css,body{}"),
152            Err(OriginError::Scheme(_) | OriginError::Opaque)
153        ));
154    }
155
156    #[test]
157    fn a_relative_url_is_not_an_origin() {
158        assert!(matches!(
159            origin_of("/theme.css"),
160            Err(OriginError::NotAUrl(_))
161        ));
162        assert!(matches!(origin_of(""), Err(OriginError::NotAUrl(_))));
163    }
164}