1use hyper::HeaderMap;
9use std::net::{Ipv4Addr, Ipv6Addr};
10
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub enum OriginPolicy {
14 #[default]
23 SameOriginOrLoopback,
24 AllowList(Vec<String>),
29 Disabled,
32}
33
34type OriginAuthority = (String, u16);
36
37fn default_port(scheme: &str) -> Option<u16> {
38 match scheme {
39 "http" | "ws" => Some(80),
40 "https" | "wss" => Some(443),
41 _ => None,
42 }
43}
44
45fn parse_origin(origin: &str) -> Option<OriginAuthority> {
47 let (scheme, rest) = origin.split_once("://")?;
48 let scheme = scheme.to_ascii_lowercase();
49 let authority = rest.strip_suffix('/').unwrap_or(rest);
51 if authority.is_empty() {
52 return None;
53 }
54 let (host, port) = split_host_port(authority)?;
55 let port = match port {
56 Some(p) => p,
57 None => default_port(&scheme)?,
58 };
59 Some((host, port))
60}
61
62fn split_host_port(authority: &str) -> Option<(String, Option<u16>)> {
64 if let Some(rest) = authority.strip_prefix('[') {
65 let (host, after) = rest.split_once(']')?;
66 let port = match after.strip_prefix(':') {
67 Some(p) => Some(p.parse().ok()?),
68 None if after.is_empty() => None,
69 None => return None,
70 };
71 Some((host.to_ascii_lowercase(), port))
72 } else if let Some((host, p)) = authority.rsplit_once(':') {
73 if host.is_empty() {
74 return None;
75 }
76 Some((host.to_ascii_lowercase(), Some(p.parse().ok()?)))
77 } else {
78 Some((authority.to_ascii_lowercase(), None))
79 }
80}
81
82fn is_loopback_host(host: &str) -> bool {
83 if host == "localhost" {
84 return true;
85 }
86 if let Ok(v4) = host.parse::<Ipv4Addr>() {
87 return v4.is_loopback();
88 }
89 if let Ok(v6) = host.parse::<Ipv6Addr>() {
90 return v6.is_loopback();
91 }
92 false
93}
94
95pub(crate) fn validate_origin(headers: &HeaderMap, policy: &OriginPolicy) -> Result<(), String> {
100 if matches!(policy, OriginPolicy::Disabled) {
101 return Ok(());
102 }
103 let Some(origin) = headers.get(hyper::header::ORIGIN) else {
104 return Ok(()); };
106 let Ok(origin) = origin.to_str() else {
107 return Err("<non-ascii>".to_string());
108 };
109
110 if let OriginPolicy::AllowList(allowed) = policy
111 && allowed.iter().any(|a| {
112 a == origin
113 || matches!(
114 (parse_origin(a), parse_origin(origin)),
115 (Some(x), Some(y)) if x == y
116 )
117 })
118 {
119 return Ok(());
120 }
121
122 let Some(parsed) = parse_origin(origin) else {
123 return Err(origin.to_string()); };
125 if is_loopback_host(&parsed.0) {
126 return Ok(());
127 }
128 Err(origin.to_string())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use hyper::header::{HOST, ORIGIN};
144
145 fn headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
146 let mut h = HeaderMap::new();
147 if let Some(o) = origin {
148 h.insert(ORIGIN, o.parse().unwrap());
149 }
150 if let Some(hh) = host {
151 h.insert(HOST, hh.parse().unwrap());
152 }
153 h
154 }
155
156 #[test]
157 fn absent_origin_is_allowed() {
158 let p = OriginPolicy::SameOriginOrLoopback;
159 assert!(validate_origin(&headers(None, Some("example.com")), &p).is_ok());
160 }
161
162 #[test]
163 fn loopback_origins_pass() {
164 let p = OriginPolicy::SameOriginOrLoopback;
165 for o in [
166 "http://localhost",
167 "http://localhost:3000",
168 "http://127.0.0.1:9999",
169 "http://127.8.4.2",
170 "http://[::1]:8080",
171 "https://LOCALHOST:8443",
172 ] {
173 assert!(
174 validate_origin(&headers(Some(o), Some("example.com")), &p).is_ok(),
175 "{o} should pass"
176 );
177 }
178 }
179
180 #[test]
188 fn matching_host_header_does_not_admit_a_foreign_origin() {
189 let p = OriginPolicy::SameOriginOrLoopback;
190 for (o, host) in [
191 ("http://evil.example.com", "evil.example.com"),
192 ("http://app.example:8080", "app.example:8080"),
193 ("http://app.example", "app.example"), ("https://app.example", "app.example"), ("https://APP.example:443", "app.example:443"),
196 ] {
197 assert!(
198 validate_origin(&headers(Some(o), Some(host)), &p).is_err(),
199 "{o} vs matching Host {host} must be rejected — Host is attacker-controlled"
200 );
201 }
202 }
203
204 #[test]
206 fn same_origin_on_a_public_host_is_reachable_via_allowlist() {
207 let p = OriginPolicy::AllowList(vec!["https://app.example".into()]);
208 assert!(
209 validate_origin(
210 &headers(Some("https://app.example"), Some("app.example")),
211 &p
212 )
213 .is_ok()
214 );
215 assert!(
216 validate_origin(
217 &headers(Some("https://evil.example"), Some("evil.example")),
218 &p
219 )
220 .is_err()
221 );
222 }
223
224 #[test]
225 fn cross_origin_null_and_garbage_are_rejected() {
226 let p = OriginPolicy::SameOriginOrLoopback;
227 for (o, host) in [
228 ("http://attacker.example", "127.0.0.1:8641"),
229 ("http://app.example:9000", "app.example:8080"), ("null", "127.0.0.1:8641"),
231 ("not a url", "127.0.0.1:8641"),
232 ] {
233 assert!(
234 validate_origin(&headers(Some(o), Some(host)), &p).is_err(),
235 "{o} vs Host {host} should be rejected"
236 );
237 }
238 }
239
240 #[test]
241 fn allowlist_is_additive_and_port_normalized() {
242 let p = OriginPolicy::AllowList(vec!["https://app.example".into(), "null".into()]);
243 let host = Some("127.0.0.1:8641");
244 assert!(validate_origin(&headers(Some("https://app.example"), host), &p).is_ok());
245 assert!(validate_origin(&headers(Some("https://app.example:443"), host), &p).is_ok());
246 assert!(validate_origin(&headers(Some("null"), host), &p).is_ok());
247 assert!(validate_origin(&headers(Some("http://localhost:3000"), host), &p).is_ok());
249 assert!(validate_origin(&headers(Some("https://other.example"), host), &p).is_err());
251 }
252
253 #[test]
254 fn disabled_skips_everything() {
255 let p = OriginPolicy::Disabled;
256 assert!(validate_origin(&headers(Some("http://attacker.example"), None), &p).is_ok());
257 }
258}