forge_server/transport/
origin.rs1use std::net::IpAddr;
12
13use axum::http::Uri;
14use forge_primitives::server::ServerConfig;
15
16const SHELL_ORIGINS: &[&str] =
20 &["tauri://localhost", "http://tauri.localhost", "https://tauri.localhost"];
21
22fn is_loopback_origin(origin: &str) -> bool {
31 let Ok(uri) = origin.parse::<Uri>() else { return false };
32 let web_scheme = uri.scheme_str().is_some_and(|scheme| {
33 scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
34 });
35 if !web_scheme {
36 return false;
37 }
38 let Some(authority) = uri.authority() else { return false };
39 let host = authority.host();
40 let Some(rest) = authority.as_str().strip_prefix(host) else { return false };
41 let port_only = rest.is_empty()
42 || rest
43 .strip_prefix(':')
44 .is_some_and(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()));
45 port_only && is_loopback_host(host)
46}
47
48fn is_loopback_host(host: &str) -> bool {
53 if host.eq_ignore_ascii_case("localhost") {
54 return true;
55 }
56 let literal = host.strip_prefix('[').and_then(|host| host.strip_suffix(']')).unwrap_or(host);
57 literal.parse::<IpAddr>().is_ok_and(|address| match address {
58 IpAddr::V4(address) => address.is_loopback(),
59 IpAddr::V6(address) => address.to_canonical().is_loopback(),
60 })
61}
62
63pub(crate) fn allows(origin: Option<&str>, server: &ServerConfig) -> bool {
69 let Some(origin) = origin else { return true };
70 let origin = origin.trim_end_matches('/');
71 if SHELL_ORIGINS.contains(&origin) {
72 return true;
73 }
74 if is_loopback_origin(origin) {
75 return true;
76 }
77 if server.allowed_origins.iter().any(|allowed| allowed.trim_end_matches('/') == origin) {
78 return true;
79 }
80 own_origins(server).iter().any(|allowed| allowed == origin)
81}
82
83fn own_origins(server: &ServerConfig) -> Vec<String> {
87 let port = server.port;
88 match server.bind {
89 IpAddr::V4(ip) if ip.is_loopback() || ip.is_unspecified() => {
90 vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}")]
91 }
92 IpAddr::V6(ip) if ip.is_loopback() || ip.is_unspecified() => {
93 vec![format!("http://[::1]:{port}"), format!("http://localhost:{port}")]
94 }
95 IpAddr::V4(ip) => vec![format!("http://{ip}:{port}")],
96 IpAddr::V6(ip) => vec![format!("http://[{ip}]:{port}")],
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use std::net::{Ipv4Addr, Ipv6Addr};
103
104 use super::*;
105
106 fn config() -> ServerConfig {
107 ServerConfig { port: 9001, ..ServerConfig::default() }
108 }
109
110 #[test]
113 fn a_foreign_origin_is_refused() {
114 assert!(!allows(Some("https://evil.example"), &config()));
115 }
116
117 #[test]
122 fn a_loopback_origin_on_any_port_is_allowed() {
123 for origin in [
124 "http://localhost:53219",
125 "https://localhost:53219",
126 "http://127.0.0.1:53219",
127 "https://127.0.0.1:53219",
128 "http://[::1]:53219",
129 "https://[::1]:53219",
130 "http://127.0.0.2:53219",
131 "http://[0:0:0:0:0:0:0:1]:53219",
132 "http://[::ffff:127.0.0.1]:53219",
133 "http://localhost",
134 "http://LOCALHOST:53219",
135 ] {
136 assert!(allows(Some(origin), &config()), "{origin}");
137 }
138 }
139
140 #[test]
146 fn a_lookalike_host_is_refused() {
147 for origin in [
148 "http://localhost.evil.com:53219",
149 "https://127.0.0.1.nip.io:53219",
150 "http://localhost@evil.com",
151 "http://[::1].evil.com",
152 "http://[::1]evil.com",
153 "http://[::1]:notaport",
154 ] {
155 assert!(!allows(Some(origin), &config()), "{origin}");
156 }
157 }
158
159 #[test]
162 fn a_non_web_origin_is_refused() {
163 for origin in ["ws://localhost:53219", "localhost:53219", "null"] {
164 assert!(!allows(Some(origin), &config()), "{origin}");
165 }
166 }
167
168 #[test]
171 fn the_shells_origins_are_allowed() {
172 for origin in SHELL_ORIGINS {
173 assert!(allows(Some(origin), &config()), "{origin}");
174 }
175 }
176
177 #[test]
181 fn the_servers_own_origin_comes_from_its_bind_and_port() {
182 let server = ServerConfig { bind: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), ..config() };
183 assert!(allows(Some("http://10.0.0.5:9001"), &server));
184 assert!(!allows(Some("http://10.0.0.5:9002"), &server));
185 assert!(!allows(Some("http://10.0.0.6:9001"), &server));
186 let server = ServerConfig {
187 bind: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
188 ..config()
189 };
190 assert!(allows(Some("http://[fe80::1]:9001"), &server));
191 assert!(!allows(Some("http://[fe80::2]:9001"), &server));
192 }
193
194 #[test]
197 fn a_configured_origin_is_allowed() {
198 let server = ServerConfig {
199 allowed_origins: vec!["http://forge-web.example:8080".to_owned()],
200 ..config()
201 };
202 assert!(allows(Some("http://forge-web.example:8080"), &server));
203 assert!(allows(Some("http://forge-web.example:8080/"), &server));
204 assert!(!allows(Some("http://forge-web.example:8081"), &server));
205 }
206
207 #[test]
209 fn an_absent_origin_is_a_native_client() {
210 assert!(allows(None, &config()));
211 }
212}