siderite_core/middleware/
csrf.rs1use super::adapt::{BoxService, Next, from_fn, impl_layer};
4use crate::error::ApiError;
5use crate::extract::Request;
6use crate::response::{IntoResponse, Response};
7use http::header::{AUTHORIZATION, COOKIE, ORIGIN, REFERER};
8use http::uri::Authority;
9use http::{HeaderMap, HeaderName, Method, StatusCode};
10use std::sync::Arc;
11use thiserror::Error;
12use tower::Layer;
13
14const SEC_FETCH_SITE: HeaderName = HeaderName::from_static("sec-fetch-site");
15const FORBIDDEN: &str = "Request forbidden.";
16
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[error("invalid CSRF origin `{0}`: expected http(s)://host[:port]")]
20pub struct CsrfOriginError(String);
21
22#[derive(Debug, Clone)]
47pub struct CsrfOrigin {
48 origins: Arc<[String]>,
49 exempt: Arc<[String]>,
50 only_with_cookie: bool,
51}
52
53impl CsrfOrigin {
54 pub fn new<I, S>(origins: I) -> Result<Self, CsrfOriginError>
64 where
65 I: IntoIterator<Item = S>,
66 S: AsRef<str>,
67 {
68 let mut parsed = Vec::new();
69 for raw in origins {
70 let raw = raw.as_ref();
71 let Some(origin) = parse_origin(raw) else {
72 return Err(CsrfOriginError(raw.to_owned()));
73 };
74 parsed.push(origin);
75 }
76 Ok(Self {
77 origins: parsed.into(),
78 exempt: Arc::default(),
79 only_with_cookie: false,
80 })
81 }
82
83 #[must_use]
91 pub fn exempt(mut self, prefix: impl Into<String>) -> Self {
92 let mut list = self.exempt.to_vec();
93 list.push(prefix.into());
94 self.exempt = list.into();
95 self
96 }
97
98 #[must_use]
108 pub fn only_with_cookie(mut self) -> Self {
109 self.only_with_cookie = true;
110 self
111 }
112
113 fn is_exempt(&self, path: &str) -> bool {
114 if path.split('/').any(|part| part == "." || part == "..") {
115 return false;
116 }
117 self.exempt.iter().any(|prefix| {
118 prefix.starts_with('/')
119 && path.strip_prefix(prefix.as_str()).is_some_and(|rest| {
120 let boundary = prefix.ends_with('/');
121 rest.is_empty() || boundary || rest.starts_with('/')
122 })
123 })
124 }
125
126 fn permits(&self, req: &Request) -> bool {
127 let headers = req.headers();
128 if is_safe(req.method()) || self.is_exempt(req.uri().path()) {
129 return true;
130 }
131 if self.only_with_cookie && !has_ambient_credentials(headers) {
132 return true;
133 }
134 match single(headers, &SEC_FETCH_SITE) {
135 One::Invalid => return false,
136 One::Value(site) if site.eq_ignore_ascii_case("cross-site") => {
137 return false;
138 }
139 One::Value(site) if site.eq_ignore_ascii_case("same-origin") => {
140 return true;
141 }
142 _ => {}
143 }
144 let claimed = match single(headers, &ORIGIN) {
145 One::Invalid => return false,
146 One::Value(origin) => parse_origin(origin),
147 One::None => match single(headers, &REFERER) {
148 One::Value(referer) => referer_origin(referer),
149 _ => None,
150 },
151 };
152 claimed.is_some_and(|origin| self.origins.contains(&origin))
153 }
154
155 fn wrap(&self, inner: BoxService) -> BoxService {
156 let this = self.clone();
157 from_fn(move |req: Request, next: Next| {
158 let allowed = this.permits(&req);
159 async move {
160 if allowed {
161 next.run(req).await
162 } else {
163 forbidden()
164 }
165 }
166 })
167 .layer(inner)
168 }
169}
170
171fn forbidden() -> Response {
172 ApiError::new(StatusCode::FORBIDDEN, FORBIDDEN).into_response()
173}
174
175fn is_safe(method: &Method) -> bool {
176 matches!(
177 *method,
178 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
179 )
180}
181
182fn has_ambient_credentials(headers: &HeaderMap) -> bool {
184 if headers.contains_key(COOKIE) {
185 return true;
186 }
187 headers.get_all(AUTHORIZATION).iter().any(|value| {
188 value.to_str().ok().is_some_and(|text| {
189 let scheme = text.split(' ').next().unwrap_or_default();
190 scheme.eq_ignore_ascii_case("basic")
191 })
192 })
193}
194
195enum One<'a> {
196 None,
197 Value(&'a str),
198 Invalid,
199}
200
201fn single<'a>(headers: &'a HeaderMap, name: &HeaderName) -> One<'a> {
203 let mut values = headers.get_all(name).iter();
204 match (values.next(), values.next()) {
205 (None, _) => One::None,
206 (Some(value), None) => value.to_str().map_or(One::Invalid, One::Value),
207 (Some(_), Some(_)) => One::Invalid,
208 }
209}
210
211fn parse_origin(raw: &str) -> Option<String> {
213 let (scheme, rest) = raw.split_once("://")?;
214 let scheme = scheme.to_ascii_lowercase();
215 let default_port = match scheme.as_str() {
216 "http" => 80,
217 "https" => 443,
218 _ => return None,
219 };
220 if rest.is_empty() || rest.contains(['/', '?', '#', '@', '\\', '%']) {
221 return None;
222 }
223 let authority = rest.parse::<Authority>().ok()?;
224 let suffix = rest.strip_prefix(authority.host())?;
225 let port = match suffix.strip_prefix(':') {
226 Some(digits) if digits.bytes().all(|b| b.is_ascii_digit()) => {
227 digits.parse::<u16>().ok().filter(|port| *port != 0)?
228 }
229 Some(_) => return None,
230 None if suffix.is_empty() => default_port,
231 None => return None,
232 };
233 let host = authority.host().to_ascii_lowercase();
234 if host.is_empty() {
235 None
236 } else if port == default_port {
237 Some(format!("{scheme}://{host}"))
238 } else {
239 Some(format!("{scheme}://{host}:{port}"))
240 }
241}
242
243fn referer_origin(referer: &str) -> Option<String> {
245 let (scheme, rest) = referer.split_once("://")?;
246 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
247 parse_origin(&format!("{scheme}://{}", rest.get(..end)?))
248}
249
250impl_layer!(CsrfOrigin);
251
252#[cfg(test)]
253#[path = "csrf_tests.rs"]
254mod tests;