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}