1use topcoat_core::context::{Cx, CxBuilder};
2use topcoat_router::{
3 Body, ForbiddenError, Layer, LayerFuture, Method, Next, Path, forbidden, header, headers,
4 method, uri,
5};
6
7use crate::config;
8
9pub fn verify_origin(cx: &Cx) -> Result<(), ForbiddenError> {
30 let headers = headers(cx);
31 let sec_fetch_site = headers
32 .get("sec-fetch-site")
33 .and_then(|value| value.to_str().ok());
34 let origin = headers
35 .get(header::ORIGIN)
36 .and_then(|value| value.to_str().ok());
37 #[allow(clippy::redundant_closure_for_method_calls)]
39 let host = headers
40 .get(header::HOST)
41 .and_then(|value| value.to_str().ok())
42 .or_else(|| uri(cx).authority().map(|authority| authority.as_str()));
43
44 if allowed(
45 method(cx),
46 sec_fetch_site,
47 origin,
48 host,
49 &config(cx).trusted_origins,
50 ) {
51 Ok(())
52 } else {
53 Err(forbidden())
54 }
55}
56
57fn allowed(
59 method: &Method,
60 sec_fetch_site: Option<&str>,
61 origin: Option<&str>,
62 host: Option<&str>,
63 trusted_origins: &[String],
64) -> bool {
65 if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
67 return true;
68 }
69 if origin.is_some_and(|origin| {
72 trusted_origins
73 .iter()
74 .any(|trusted| trusted.eq_ignore_ascii_case(origin))
75 }) {
76 return true;
77 }
78 if let Some(site) = sec_fetch_site {
81 return matches!(site, "same-origin" | "none");
82 }
83 if let Some(origin) = origin {
86 return origin_host(origin)
87 .zip(host)
88 .is_some_and(|(origin_host, host)| origin_host.eq_ignore_ascii_case(host));
89 }
90 true
92}
93
94fn origin_host(origin: &str) -> Option<&str> {
96 origin.split_once("://").map(|(_, host)| host)
97}
98
99#[derive(Debug, Clone, Copy, Default)]
107pub struct OriginLayer;
108
109impl OriginLayer {
110 #[must_use]
112 pub const fn new() -> Self {
113 Self
114 }
115}
116
117impl Layer for OriginLayer {
118 fn path(&self) -> &Path {
119 Path::new("/")
120 }
121
122 fn handle<'a>(&'a self, cx: &'a mut CxBuilder, body: Body, next: Next<'a>) -> LayerFuture<'a> {
123 match verify_origin(cx) {
124 Ok(()) => next.run(cx, body),
125 Err(error) => Box::pin(async move { Err(error.into()) }),
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 const NO_TRUST: &[String] = &[];
135
136 fn post_allowed(
137 sec_fetch_site: Option<&str>,
138 origin: Option<&str>,
139 host: Option<&str>,
140 ) -> bool {
141 allowed(&Method::POST, sec_fetch_site, origin, host, NO_TRUST)
142 }
143
144 #[test]
145 fn safe_methods_pass_even_cross_site() {
146 for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
147 assert!(allowed(
148 &method,
149 Some("cross-site"),
150 Some("https://evil.example"),
151 Some("app.example"),
152 NO_TRUST,
153 ));
154 }
155 }
156
157 #[test]
158 fn same_origin_and_direct_navigation_pass() {
159 assert!(post_allowed(Some("same-origin"), None, Some("app.example")));
160 assert!(post_allowed(Some("none"), None, Some("app.example")));
161 }
162
163 #[test]
164 fn same_site_and_cross_site_are_rejected() {
165 assert!(!post_allowed(
168 Some("same-site"),
169 Some("https://evil.app.example"),
170 Some("app.example"),
171 ));
172 assert!(!post_allowed(
173 Some("cross-site"),
174 Some("https://evil.example"),
175 Some("app.example"),
176 ));
177 }
178
179 #[test]
180 fn trusted_origins_pass_even_cross_site() {
181 let trusted = vec!["https://accounts.example".to_owned()];
182 assert!(allowed(
183 &Method::POST,
184 Some("cross-site"),
185 Some("https://accounts.example"),
186 Some("app.example"),
187 &trusted,
188 ));
189 assert!(!allowed(
192 &Method::POST,
193 Some("cross-site"),
194 None,
195 Some("app.example"),
196 &trusted,
197 ));
198 }
199
200 #[test]
201 fn origin_fallback_compares_hosts() {
202 assert!(post_allowed(
203 None,
204 Some("https://app.example"),
205 Some("app.example"),
206 ));
207 assert!(post_allowed(
209 None,
210 Some("https://App.Example:8443"),
211 Some("app.example:8443"),
212 ));
213 assert!(!post_allowed(
214 None,
215 Some("https://evil.example"),
216 Some("app.example"),
217 ));
218 assert!(!post_allowed(
219 None,
220 Some("https://app.example:8443"),
221 Some("app.example"),
222 ));
223 }
224
225 #[test]
226 fn opaque_origin_is_rejected() {
227 assert!(!post_allowed(None, Some("null"), Some("app.example")));
228 }
229
230 #[test]
231 fn origin_without_a_request_host_is_rejected() {
232 assert!(!post_allowed(None, Some("https://app.example"), None));
233 }
234
235 #[test]
236 fn non_browser_requests_pass() {
237 assert!(post_allowed(None, None, Some("app.example")));
238 assert!(post_allowed(None, None, None));
239 }
240}