Skip to main content

aven_core/sync/client/
origin.rs

1//! Server origins that encrypted sync accepts.
2use anyhow::{Context, Result, ensure};
3use url::Url;
4
5/// Longest server origin; invitations carry its length in one byte.
6pub const MAX_SERVER_BYTES: usize = 255;
7
8/// The official Aven Cloud sync host, which serves only HTTPS.
9const AVEN_CLOUD_HOST: &str = "sync.aventasks.dev";
10
11/// Validates `origin` for encrypted transport and returns the endpoint at
12/// `path`: HTTP or HTTPS with a host and no credentials, query, fragment or
13/// path. The official Cloud host requires HTTPS on every port.
14pub(crate) fn endpoint(origin: &str, path: &str) -> Result<Url> {
15    // Inspect the supplied authority/path too: URL parsing erases empty
16    // userinfo and resolves dot segments before exposing those fields.
17    let (_, address) = origin
18        .split_once("://")
19        .ok_or_else(|| anyhow::anyhow!("error bootstrap-origin"))?;
20    let (authority, suffix) = address.split_once('/').unwrap_or((address, ""));
21    ensure!(
22        !authority.contains('@') && !origin.contains('\\') && suffix.is_empty(),
23        "error bootstrap-origin"
24    );
25    let mut url = Url::parse(origin).map_err(|_| anyhow::anyhow!("error bootstrap-origin"))?;
26    ensure!(
27        matches!(url.scheme(), "http" | "https")
28            && url.host_str().is_some()
29            && url.username().is_empty()
30            && url.password().is_none()
31            && url.query().is_none()
32            && url.fragment().is_none()
33            && url.path() == "/",
34        "error bootstrap-origin"
35    );
36    // The parsed host is lowercase and percent-decoded; a trailing dot names
37    // the same DNS host.
38    let host = url.host_str().unwrap_or_default();
39    ensure!(
40        url.scheme() == "https" || host.strip_suffix('.').unwrap_or(host) != AVEN_CLOUD_HOST,
41        "error sync-cloud-https-required"
42    );
43    url.set_path(path);
44    Ok(url)
45}
46
47/// Validates a server URL for encrypted transport and returns its origin.
48pub fn server_origin(url: &str) -> Result<String> {
49    let url = endpoint(url, "/").context(
50        "error sync-server-url-invalid hint=\"use an HTTP or HTTPS origin with no path, query or credentials\"",
51    )?;
52    let origin = url.origin().ascii_serialization();
53    ensure!(
54        origin.len() <= MAX_SERVER_BYTES,
55        "error sync-server-url-too-long hint=\"use a server origin of at most 255 bytes\""
56    );
57    Ok(origin)
58}
59
60/// Identifies Cloud only after validating the complete server origin.
61pub fn is_aven_cloud(origin: &str) -> bool {
62    endpoint(origin, "/").is_ok_and(|url| {
63        let host = url.host_str().unwrap_or_default();
64        host.strip_suffix('.').unwrap_or(host) == AVEN_CLOUD_HOST
65            && url.port_or_known_default() == Some(443)
66    })
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn cloud_identity_uses_the_validated_origin() {
75        for origin in [
76            "https://sync.aventasks.dev",
77            "HTTPS://SYNC.AVENTASKS.DEV:443/",
78            "https://SYNC.AVENTASKS.DEV.:443/",
79            "https://sync.aventasks.dev./",
80            "https://%73ync.aventasks.dev",
81        ] {
82            assert!(is_aven_cloud(origin), "{origin}");
83        }
84        for origin in [
85            "http://sync.aventasks.dev",
86            "https://sync.aventasks.dev:444",
87            "https://sync.aventasks.dev.evil.example",
88            "https://sync.aventasks.dev/private",
89            "https://user:secret@sync.aventasks.dev",
90            "https://sync.aventasks.dev/?x=1",
91            "https://sync.aventasks.dev/#x",
92            "https://@sync.aventasks.dev",
93            "https://sync.aventasks.dev/private/..",
94            "https://sync.aventasks.dev/.",
95        ] {
96            assert!(!is_aven_cloud(origin), "{origin}");
97        }
98        for origin in [
99            "http://SYNC.AVENTASKS.DEV:443",
100            "http://sync.aventasks.dev.:80",
101            "http://%73ync.aventasks.dev:3746",
102        ] {
103            assert!(endpoint(origin, "/e2ee/bootstrap/v1").is_err(), "{origin}");
104        }
105    }
106
107    #[test]
108    fn accepts_http_and_https_origins() {
109        for origin in [
110            "http://127.0.0.1:3746",
111            "http://100.100.20.30:3746",
112            "http://sync.private.example:3746",
113            "https://sync.example.com",
114        ] {
115            assert_eq!(server_origin(origin).unwrap(), origin);
116        }
117    }
118
119    #[test]
120    fn official_cloud_host_requires_https_without_changing_origins() {
121        for origin in [
122            "http://sync.aventasks.dev",
123            "HTTP://SYNC.AVENTASKS.DEV",
124            "http://sync.aventasks.dev:80",
125            "http://sync.aventasks.dev:443",
126            "http://sync.aventasks.dev:3746",
127            "http://sync.aventasks.dev.",
128            "http://%73ync.aventasks.dev",
129        ] {
130            let error = endpoint(origin, "/e2ee/bootstrap/v1").unwrap_err();
131            assert_eq!(
132                error.to_string(),
133                "error sync-cloud-https-required",
134                "{origin}"
135            );
136            let error = server_origin(origin).unwrap_err();
137            assert!(
138                format!("{error:#}").contains("sync-cloud-https-required"),
139                "{origin}: {error:#}"
140            );
141        }
142        for (origin, canonical) in [
143            ("https://sync.aventasks.dev", "https://sync.aventasks.dev"),
144            (
145                "HTTPS://SYNC.AVENTASKS.DEV:443/",
146                "https://sync.aventasks.dev",
147            ),
148            ("https://sync.aventasks.dev.", "https://sync.aventasks.dev."),
149            (
150                "https://sync.aventasks.dev:444",
151                "https://sync.aventasks.dev:444",
152            ),
153            (
154                "http://sync.aventasks.dev.example",
155                "http://sync.aventasks.dev.example",
156            ),
157            (
158                "http://dev-sync.aventasks.dev",
159                "http://dev-sync.aventasks.dev",
160            ),
161        ] {
162            assert_eq!(server_origin(origin).unwrap(), canonical, "{origin}");
163        }
164    }
165
166    #[test]
167    fn rejects_non_http_and_non_origin_urls() {
168        for origin in [
169            "ftp://sync.example.com",
170            "https://user:secret@sync.example.com",
171            "https://sync.example.com/private",
172            "https://sync.example.com/?token=secret",
173            "https://sync.example.com/#fragment",
174            "https://",
175            "not a URL",
176        ] {
177            let error = server_origin(origin).unwrap_err();
178            assert!(
179                format!("{error:#}").contains("sync-server-url-invalid"),
180                "{origin}: {error:#}"
181            );
182        }
183    }
184}