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