1pub fn normalize_origin(origin: &str) -> String {
28 let origin = origin.trim();
29 let Some((scheme, authority)) = origin.split_once("://") else {
30 return origin.to_ascii_lowercase();
31 };
32 let scheme = scheme.to_ascii_lowercase();
33 let (host, port) = split_authority(authority);
34 let mut out = format!("{scheme}://{}", host.to_ascii_lowercase());
35 if let Some(port) = port {
36 if !port.is_empty() && !is_default_port(&scheme, port) {
37 out.push(':');
38 out.push_str(port);
39 }
40 }
41 out
42}
43
44fn split_authority(authority: &str) -> (&str, Option<&str>) {
46 if authority.starts_with('[') {
47 if let Some(close) = authority.find(']') {
48 match authority[close + 1..].strip_prefix(':') {
49 Some(port) => return (&authority[..close + 1], Some(port)),
50 None => return (authority, None),
51 }
52 }
53 return (authority, None);
54 }
55 match authority.split_once(':') {
57 Some((host, port)) => (host, Some(port)),
58 None => (authority, None),
59 }
60}
61
62fn is_default_port(scheme: &str, port: &str) -> bool {
64 port.parse::<u16>().is_ok_and(|p| {
65 matches!(
66 (scheme, p),
67 ("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443)
68 )
69 })
70}
71
72pub fn origin_allowed(origin: Option<&str>, allowed_origins: &[String]) -> bool {
84 if allowed_origins.is_empty() {
85 return true;
86 }
87 let Some(origin) = origin else {
88 return false;
89 };
90 let normalized = normalize_origin(origin);
91 allowed_origins
92 .iter()
93 .any(|entry| normalize_origin(entry) == normalized)
94}
95
96#[cfg(feature = "axum")]
100pub fn origin_allowed_in_parts(parts: &http::request::Parts, allowed_origins: &[String]) -> bool {
101 let origin = parts
102 .headers
103 .get(http::header::ORIGIN)
104 .and_then(|value| value.to_str().ok());
105 origin_allowed(origin, allowed_origins)
106}
107
108#[allow(
112 clippy::unwrap_used,
113 clippy::expect_used,
114 clippy::indexing_slicing,
115 clippy::panic
116)]
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn normalize_origin_basic_and_case() {
123 assert_eq!(
124 normalize_origin("https://example.com"),
125 "https://example.com"
126 );
127 assert_eq!(
128 normalize_origin("HTTPS://EXAMPLE.COM"),
129 "https://example.com"
130 );
131 assert_eq!(
132 normalize_origin("https://EXAMPLE.com"),
133 "https://example.com"
134 );
135 assert_eq!(
136 normalize_origin(" https://example.com "),
137 "https://example.com"
138 );
139 }
140
141 #[test]
142 fn normalize_origin_default_ports_omitted() {
143 assert_eq!(
144 normalize_origin("https://example.com:443"),
145 "https://example.com"
146 );
147 assert_eq!(
148 normalize_origin("http://example.com:80"),
149 "http://example.com"
150 );
151 assert_eq!(
152 normalize_origin("wss://example.com:443"),
153 "wss://example.com"
154 );
155 assert_eq!(normalize_origin("ws://example.com:80"), "ws://example.com");
156 }
157
158 #[test]
159 fn normalize_origin_custom_ports_kept() {
160 assert_eq!(
161 normalize_origin("https://example.com:8443"),
162 "https://example.com:8443"
163 );
164 assert_ne!(
166 normalize_origin("https://example.com:8443"),
167 normalize_origin("https://example.com")
168 );
169 assert_ne!(
170 normalize_origin("http://example.com:8080"),
171 normalize_origin("http://example.com")
172 );
173 }
174
175 #[test]
176 fn normalize_origin_ipv6() {
177 assert_eq!(
178 normalize_origin("https://[2001:DB8::1]:443"),
179 "https://[2001:db8::1]"
180 );
181 assert_eq!(normalize_origin("http://[::1]:8080"), "http://[::1]:8080");
182 assert_eq!(normalize_origin("http://[::1]"), "http://[::1]");
183 }
184
185 #[test]
186 fn normalize_origin_no_scheme_fail_closed() {
187 assert_eq!(normalize_origin("example.com"), "example.com");
190 assert_eq!(normalize_origin("localhost:3000"), "localhost:3000");
191 }
192
193 #[test]
194 fn origin_allowed_empty_list_allows_all() {
195 assert!(origin_allowed(Some("https://anything.dev"), &[]));
197 assert!(origin_allowed(None, &[]));
198 }
199
200 #[test]
201 fn origin_allowed_match_and_normalization() {
202 let allowed = vec!["https://example.com".to_string()];
203 assert!(origin_allowed(Some("https://example.com"), &allowed));
204 assert!(origin_allowed(Some("https://example.com:443"), &allowed));
206 assert!(origin_allowed(Some("HTTPS://EXAMPLE.COM"), &allowed));
208 let allowed443 = vec!["https://example.com:443".to_string()];
210 assert!(origin_allowed(Some("https://example.com"), &allowed443));
211 }
212
213 #[test]
214 fn origin_allowed_missing_origin_rejected_when_listed() {
215 let allowed = vec!["https://example.com".to_string()];
216 assert!(!origin_allowed(None, &allowed));
217 }
218
219 #[test]
220 fn origin_allowed_no_wildcard_or_suffix_matching() {
221 let allowed = vec!["https://example.com".to_string()];
222 assert!(!origin_allowed(Some("https://evil-example.com"), &allowed));
223 assert!(!origin_allowed(
224 Some("https://example.com.evil.com"),
225 &allowed
226 ));
227 assert!(!origin_allowed(Some("https://api.example.com"), &allowed));
228 assert!(!origin_allowed(Some("https://example.com:8443"), &allowed));
229 }
230
231 #[test]
232 fn origin_allowed_scheme_matters() {
233 let allowed = vec!["https://example.com".to_string()];
234 assert!(!origin_allowed(Some("http://example.com"), &allowed));
235 assert!(!origin_allowed(Some("ws://example.com"), &allowed));
236 }
237
238 #[cfg(feature = "axum")]
239 mod parts_tests {
240 use super::*;
241 use http::{HeaderMap, Request};
242
243 fn parts_with_origin(origin: Option<&str>) -> http::request::Parts {
244 let mut builder = Request::builder().uri("/ws");
245 if let Some(o) = origin {
246 builder = builder.header(http::header::ORIGIN, o);
247 }
248 let (parts, _) = builder.body(()).unwrap().into_parts();
249 parts
250 }
251
252 #[test]
253 fn origin_allowed_in_parts_reads_header() {
254 let allowed = vec!["https://example.com".to_string()];
255 assert!(origin_allowed_in_parts(
256 &parts_with_origin(Some("https://example.com")),
257 &allowed
258 ));
259 assert!(!origin_allowed_in_parts(
260 &parts_with_origin(Some("https://evil.com")),
261 &allowed
262 ));
263 assert!(!origin_allowed_in_parts(&parts_with_origin(None), &allowed));
264 assert!(origin_allowed_in_parts(&parts_with_origin(None), &[]));
265 }
266
267 #[test]
268 fn origin_allowed_in_parts_host_header_ignored() {
269 let allowed = vec!["https://example.com".to_string()];
271 let mut headers = HeaderMap::new();
272 headers.insert(http::header::HOST, "example.com".parse().unwrap());
273 let req = Request::builder().uri("/ws").body(()).unwrap();
274 let (mut parts, _) = req.into_parts();
275 parts.headers = headers;
276 assert!(!origin_allowed_in_parts(&parts, &allowed));
277 }
278 }
279}