Skip to main content

gglib_core/
is_local_origin.rs

1//! Origin validation utilities for CORS and similar security checks.
2//!
3//! Provides [`is_local_origin`] to determine whether a URL origin
4//! (e.g. from the `Origin` HTTP header) is a trusted local address.
5
6use url::Url;
7
8/// Returns `true` if the origin string resolves to a local host.
9///
10/// Accepted hosts: `localhost`, `127.0.0.1`, `::1`, `tauri.localhost`.
11///
12/// Schemes `http` and `https` are accepted; ports are ignored.
13/// Tauri custom schemes (`tauri://localhost`, `asset://localhost`) are also accepted.
14/// URLs with userinfo (e.g. `http://user@localhost`) are rejected to prevent
15/// credential-injection bypasses.
16/// Malformed URLs, missing hosts, and non-local hosts return `false`.
17pub fn is_local_origin(origin: &str) -> bool {
18    // Handle Tauri custom schemes that Url::parse cannot parse (not registered URI schemes).
19    if let Some(stripped) = origin
20        .strip_prefix("tauri://")
21        .or_else(|| origin.strip_prefix("asset://"))
22    {
23        let host = stripped.trim_end_matches('/');
24        // Reject userinfo in custom schemes (defensive symmetry with the
25        // standard URL userinfo guard below).
26        if host.contains('@') {
27            return false;
28        }
29        return matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1");
30    }
31
32    let Ok(parsed) = Url::parse(origin) else {
33        return false;
34    };
35
36    // Only allow http/https schemes
37    if !matches!(parsed.scheme(), "http" | "https") {
38        return false;
39    }
40
41    let Some(host_str) = parsed.host_str() else {
42        return false;
43    };
44
45    // Reject URLs with userinfo (e.g. http://user@localhost) to prevent
46    // credential-injection bypasses.
47    if !parsed.username().is_empty() || parsed.password().is_some() {
48        return false;
49    }
50
51    // `url::Url::host_str()` returns IPv6 addresses with brackets (e.g. `[::1]`),
52    // so strip them for comparison.
53    let host = host_str.trim_start_matches('[').trim_end_matches(']');
54
55    // RFC 3986 §3.2.2: host comparison is case-insensitive for IPv4 and
56    // domain names. Normalizing to lowercase documents this intent.
57    matches!(
58        host.to_lowercase().as_str(),
59        "localhost" | "127.0.0.1" | "::1" | "tauri.localhost"
60    )
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn accepts_localhost_http() {
69        assert!(is_local_origin("http://localhost"));
70    }
71
72    #[test]
73    fn accepts_localhost_https() {
74        assert!(is_local_origin("https://localhost"));
75    }
76
77    #[test]
78    fn accepts_localhost_with_port() {
79        assert!(is_local_origin("http://localhost:3000"));
80        assert!(is_local_origin("https://localhost:8080"));
81    }
82
83    #[test]
84    fn accepts_127_0_0_1() {
85        assert!(is_local_origin("http://127.0.0.1"));
86        assert!(is_local_origin("https://127.0.0.1"));
87        assert!(is_local_origin("http://127.0.0.1:9887"));
88    }
89
90    #[test]
91    fn accepts_ipv6_loopback() {
92        assert!(is_local_origin("http://[::1]"));
93        assert!(is_local_origin("https://[::1]"));
94        assert!(is_local_origin("http://[::1]:3000"));
95    }
96
97    #[test]
98    fn rejects_subdomain_of_localhost() {
99        assert!(!is_local_origin("http://localhost.evil.com"));
100    }
101
102    #[test]
103    fn rejects_notlocalhost() {
104        assert!(!is_local_origin("http://notlocalhost"));
105    }
106
107    #[test]
108    fn rejects_non_loopback_ip() {
109        assert!(!is_local_origin("http://127.0.0.2"));
110    }
111
112    #[test]
113    fn rejects_external_host() {
114        assert!(!is_local_origin("https://example.com"));
115    }
116
117    #[test]
118    fn rejects_non_loopback_ipv6() {
119        assert!(!is_local_origin("http://[::2]"));
120    }
121
122    #[test]
123    fn rejects_empty_string() {
124        assert!(!is_local_origin(""));
125    }
126
127    #[test]
128    fn rejects_malformed_url() {
129        assert!(!is_local_origin("not-a-url"));
130    }
131
132    #[test]
133    fn rejects_non_http_scheme() {
134        assert!(!is_local_origin("ftp://localhost"));
135    }
136
137    #[test]
138    fn rejects_url_encoded_bypass() {
139        // `http://localhost:8080@evil.com` parses with host = "evil.com"
140        assert!(!is_local_origin("http://localhost:8080@evil.com"));
141    }
142
143    #[test]
144    fn accepts_tauri_scheme_localhost() {
145        assert!(is_local_origin("tauri://localhost"));
146        assert!(is_local_origin("tauri://localhost/"));
147    }
148
149    #[test]
150    fn accepts_asset_scheme_localhost() {
151        assert!(is_local_origin("asset://localhost"));
152    }
153
154    #[test]
155    fn accepts_http_tauri_localhost() {
156        assert!(is_local_origin("http://tauri.localhost"));
157        assert!(is_local_origin("http://tauri.localhost:3000"));
158    }
159
160    #[test]
161    fn rejects_userinfo_localhost() {
162        // http://user@localhost parses with host="localhost" — must be rejected via userinfo guard
163        assert!(!is_local_origin("http://user@localhost"));
164    }
165
166    #[test]
167    fn rejects_userinfo_with_password() {
168        assert!(!is_local_origin("http://user:pass@localhost"));
169    }
170
171    #[test]
172    fn rejects_tauri_scheme_userinfo() {
173        assert!(!is_local_origin("tauri://user@localhost"));
174    }
175
176    #[test]
177    fn rejects_asset_scheme_external_host() {
178        assert!(!is_local_origin("asset://evil.com"));
179    }
180
181    #[test]
182    fn accepts_https_tauri_localhost() {
183        assert!(is_local_origin("https://tauri.localhost"));
184    }
185
186    #[test]
187    fn accepts_uppercase_localhost() {
188        assert!(is_local_origin("http://LOCALHOST"));
189    }
190}