Skip to main content

pv/
origin.rs

1//! The origin of a URL, for keeping the write token to the page's own server.
2
3/// The origin of an absolute URL: `scheme://host[:port]` in lowercase,
4/// without user info or a default port, and with a websocket's scheme as
5/// the page's (`ws` as `http`, `wss` as `https`), so a socket and a page
6/// on one server have one origin. `None` for anything else, including a
7/// URL with a backslash, a control character or white space: a browser
8/// reads those otherwise (`\` is `/` to it, so `ws://evil\@server/` is a
9/// socket to `evil`).
10pub fn url_origin(url: &str) -> Option<String> {
11    // In a browser, first as the browser reads it.
12    #[cfg(target_arch = "wasm32")]
13    let url = &web_sys::Url::new(url).ok()?.href();
14    let url = url.trim();
15    if url
16        .chars()
17        .any(|c| c == '\\' || c.is_control() || c.is_whitespace())
18    {
19        return None;
20    }
21    let (scheme, rest) = url.split_once("://")?;
22    let scheme = match scheme.to_ascii_lowercase().as_str() {
23        "http" | "ws" => "http",
24        "https" | "wss" => "https",
25        _ => return None,
26    };
27    let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
28    let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
29    if host.is_empty() {
30        return None;
31    }
32    let host = host.to_ascii_lowercase();
33    let default = if scheme == "http" { ":80" } else { ":443" };
34    let host = host.strip_suffix(default).unwrap_or(&host);
35    Some(format!("{scheme}://{host}"))
36}
37
38/// Whether two URLs are on one server (see [`url_origin`]).
39pub fn same_origin(a: &str, b: &str) -> bool {
40    url_origin(a).is_some_and(|a| Some(a) == url_origin(b))
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn origins() {
49        let o = |u| url_origin(u);
50        assert_eq!(
51            o("http://Host:8080/a.bob?x").as_deref(),
52            Some("http://host:8080")
53        );
54        assert_eq!(o("https://host:443/").as_deref(), Some("https://host"));
55        assert_eq!(o("wss://host/pvws/pv").as_deref(), Some("https://host"));
56        assert_eq!(o("ws://u:p@host:80").as_deref(), Some("http://host"));
57        assert_eq!(
58            o("http://[::1]:8080/x").as_deref(),
59            Some("http://[::1]:8080")
60        );
61        assert_eq!(o("file:///x"), None);
62        assert_eq!(o("a.bob"), None);
63        assert_eq!(o("http:///x"), None);
64    }
65
66    #[test]
67    fn same_origins() {
68        assert!(same_origin(
69            "http://server:8080/?display=a.bob",
70            "ws://server:8080/pvws/pv"
71        ));
72        assert!(same_origin("https://server/x", "wss://server:443/pvws/pv"));
73        assert!(!same_origin("https://server/x", "ws://server/pvws/pv"));
74        assert!(!same_origin(
75            "http://server:8080/",
76            "ws://evil:8080/pvws/pv"
77        ));
78        assert!(!same_origin("http://server:8080/", "https://evil/x.bob"));
79        // A user-info trick names another host.
80        assert!(!same_origin("http://server/", "http://server@evil/x.bob"));
81        assert!(!same_origin("x", "x"));
82        // A browser takes `\` for `/`: these are sockets to `evil`.
83        let page = "http://server:8080/";
84        for socket in [
85            r"ws://evil\@server:8080/pvws/pv",
86            r"ws://evil\\@server:8080/pvws/pv",
87            "ws://evil\t@server:8080/pvws/pv",
88            "ws://evil\n@server:8080/pvws/pv",
89            "ws://evil @server:8080/pvws/pv",
90            r"ws:\\server:8080/pvws/pv",
91        ] {
92            assert!(!same_origin(page, socket), "{socket}");
93            assert_eq!(url_origin(socket), None, "{socket}");
94        }
95        // Encoded, a backslash is only text in the user info.
96        assert!(same_origin(page, "ws://evil%5C@server:8080/pvws/pv"));
97    }
98}