ocre/security/browser.rs
1//! `AllowBrowser`: turns away outdated browsers with `406 Not Acceptable` (Rails' `allow_browser`).
2
3use std::{
4 convert::Infallible,
5 future::Future,
6 pin::Pin,
7 sync::Arc,
8 task::{Context, Poll},
9};
10
11use axum::{
12 extract::Request,
13 http::{HeaderValue, StatusCode, header},
14 response::{IntoResponse, Response},
15};
16use tower_layer::Layer;
17use tower_service::Service;
18
19/// A browser family that [`AllowBrowser`] recognizes in the `User-Agent` header.
20///
21/// # Examples
22///
23/// ```
24/// use ocre::security::Browser;
25///
26/// let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) \
27/// Version/17.4 Safari/605.1.15";
28/// assert_eq!(Browser::detect(ua), Some((Browser::Safari, (17, 4))));
29/// assert_eq!(Browser::detect("curl/8.7.1"), None);
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Browser {
33 /// Google Chrome and Chromium (`Chrome/`, `CriOS/` on iOS).
34 Chrome,
35 /// Microsoft Edge (`Edg/`, `EdgA/`, `EdgiOS/`).
36 Edge,
37 /// Mozilla Firefox (`Firefox/`).
38 Firefox,
39 /// Internet Explorer (`MSIE`, `Trident/`).
40 InternetExplorer,
41 /// Opera (`OPR/`).
42 Opera,
43 /// Apple Safari (`Version/... Safari/`).
44 Safari,
45}
46
47impl Browser {
48 /// The browser and its `(major, minor)` version named by a `User-Agent`, or `None` for anything else (bots, `curl`).
49 ///
50 /// Tokens are checked from the most specific: Edge and Opera also send
51 /// `Chrome/`, and Chrome also sends `Safari/`. Pure CPU.
52 ///
53 /// # Examples
54 ///
55 /// ```
56 /// use ocre::security::Browser;
57 ///
58 /// let edge = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 \
59 /// Safari/537.36 Edg/124.0.2478.51";
60 /// assert_eq!(Browser::detect(edge), Some((Browser::Edge, (124, 0))));
61 /// assert_eq!(Browser::detect("Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)"),
62 /// Some((Browser::InternetExplorer, (8, 0))));
63 /// ```
64 pub fn detect(user_agent: &str) -> Option<(Browser, (u32, u32))> {
65 const TOKENS: &[(Browser, &str)] = &[
66 (Browser::InternetExplorer, "MSIE "),
67 (Browser::InternetExplorer, "rv:"),
68 (Browser::Edge, "Edg/"),
69 (Browser::Edge, "EdgA/"),
70 (Browser::Edge, "EdgiOS/"),
71 (Browser::Opera, "OPR/"),
72 (Browser::Firefox, "Firefox/"),
73 (Browser::Chrome, "CriOS/"),
74 (Browser::Chrome, "Chrome/"),
75 (Browser::Safari, "Version/"),
76 ];
77 let trident = user_agent.contains("Trident/");
78 TOKENS.iter().find_map(|&(browser, token)| {
79 let applies = match (browser, token) {
80 (Browser::InternetExplorer, "rv:") => trident,
81 (Browser::Safari, _) => user_agent.contains("Safari/"),
82 _ => true,
83 };
84 let start = user_agent.find(token).filter(|_| applies)? + token.len();
85 Some((browser, version(&user_agent[start..])?))
86 })
87 }
88}
89
90/// `120.0.6099.71` is `(120, 0)`; `8.0;` is `(8, 0)`.
91fn version(text: &str) -> Option<(u32, u32)> {
92 let end = text.find(|c: char| !c.is_ascii_digit() && c != '.').unwrap_or(text.len());
93 let mut parts = text[..end].split('.');
94 let major = parts.next()?.parse().ok()?;
95 let minor = parts.next().and_then(|minor| minor.parse().ok()).unwrap_or(0);
96 Some((major, minor))
97}
98
99/// The page outdated browsers get, with status 406.
100const UNSUPPORTED: &str = "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
101<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Your browser is not supported (406)</title>\
102</head><body><h1>Your browser is not supported.</h1><p>Please upgrade your browser to continue.</p></body></html>\n";
103
104/// Tower layer that answers `406 Not Acceptable` to browsers older than the versions it allows (Rails' `allow_browser`).
105///
106/// [`modern`](Self::modern) is Rails' `versions: :modern`: browsers with
107/// WebP images, web push, badges, import maps, CSS nesting and CSS `:has`
108/// (Safari 17.2, Chrome and Edge 120, Firefox 121, Opera 106; no Internet
109/// Explorer). Requests whose `User-Agent` is missing or names no known
110/// browser (bots, `curl`, API clients, uptime checks) always pass, as in
111/// Rails. Outdated browsers get a short HTML page asking to upgrade
112/// (replace it with [`page`](Self::page)). Pure CPU, no binding call.
113///
114/// Apply it to the routes of pages (`.layer(...)` on a `Router`), not to
115/// JSON APIs or webhooks, whose clients are not browsers anyway.
116///
117/// # Examples
118///
119/// ```
120/// use axum::{Router, routing::get};
121/// use ocre::security::{AllowBrowser, Browser};
122///
123/// let app: Router = Router::new().route("/", get(|| async { "home" })).layer(AllowBrowser::modern());
124/// # let _ = app;
125///
126/// let policy = AllowBrowser::new().minimum(Browser::Firefox, 115, 0).deny(Browser::InternetExplorer);
127/// assert!(policy.allows(Some("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0")));
128/// assert!(!policy.allows(Some("Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0")));
129/// assert!(policy.allows(None));
130/// ```
131#[derive(Debug, Clone)]
132pub struct AllowBrowser {
133 rules: Vec<(Browser, Option<(u32, u32)>)>,
134 page: Arc<str>,
135}
136
137impl Default for AllowBrowser {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl AllowBrowser {
144 /// A policy that allows every browser; add limits with [`minimum`](Self::minimum) and [`deny`](Self::deny).
145 ///
146 /// # Examples
147 ///
148 /// ```
149 /// assert!(ocre::security::AllowBrowser::new().allows(Some("Mozilla/4.0 (compatible; MSIE 6.0)")));
150 /// ```
151 pub fn new() -> Self {
152 Self { rules: Vec::new(), page: Arc::from(UNSUPPORTED) }
153 }
154
155 /// Rails' `allow_browser versions: :modern`: Safari 17.2, Chrome and Edge 120, Firefox 121, Opera 106, no Internet Explorer.
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use ocre::security::AllowBrowser;
161 ///
162 /// let old_chrome = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36";
163 /// assert!(!AllowBrowser::modern().allows(Some(old_chrome)));
164 /// ```
165 pub fn modern() -> Self {
166 Self::new()
167 .minimum(Browser::Safari, 17, 2)
168 .minimum(Browser::Chrome, 120, 0)
169 .minimum(Browser::Edge, 120, 0)
170 .minimum(Browser::Firefox, 121, 0)
171 .minimum(Browser::Opera, 106, 0)
172 .deny(Browser::InternetExplorer)
173 }
174
175 /// Allows `browser` from version `major.minor` on; replaces an earlier rule for the same browser.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use ocre::security::{AllowBrowser, Browser};
181 ///
182 /// let policy = AllowBrowser::modern().minimum(Browser::Safari, 16, 0);
183 /// let safari_16 = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 \
184 /// (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1";
185 /// assert!(policy.allows(Some(safari_16)));
186 /// ```
187 pub fn minimum(self, browser: Browser, major: u32, minor: u32) -> Self {
188 self.rule(browser, Some((major, minor)))
189 }
190
191 /// Refuses every version of `browser` (Rails' `ie: false`).
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use ocre::security::{AllowBrowser, Browser};
197 ///
198 /// let policy = AllowBrowser::new().deny(Browser::InternetExplorer);
199 /// assert!(!policy.allows(Some("Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko")));
200 /// ```
201 pub fn deny(self, browser: Browser) -> Self {
202 self.rule(browser, None)
203 }
204
205 fn rule(mut self, browser: Browser, minimum: Option<(u32, u32)>) -> Self {
206 self.rules.retain(|(existing, _)| *existing != browser);
207 self.rules.push((browser, minimum));
208 self
209 }
210
211 /// Replaces the HTML page sent with the 406 (by default a short "please upgrade your browser" page).
212 ///
213 /// # Examples
214 ///
215 /// ```
216 /// let policy = ocre::security::AllowBrowser::modern().page("<h1>Please use a recent browser.</h1>");
217 /// # let _ = policy;
218 /// ```
219 pub fn page(mut self, html: impl Into<String>) -> Self {
220 self.page = Arc::from(html.into());
221 self
222 }
223
224 /// Whether a request with this `User-Agent` passes: a missing header or an unknown client always does.
225 ///
226 /// # Examples
227 ///
228 /// ```
229 /// use ocre::security::AllowBrowser;
230 ///
231 /// let policy = AllowBrowser::modern();
232 /// assert!(policy.allows(Some("Googlebot/2.1 (+http://www.google.com/bot.html)")));
233 /// assert!(policy.allows(None));
234 /// ```
235 pub fn allows(&self, user_agent: Option<&str>) -> bool {
236 let Some((browser, version)) = user_agent.and_then(Browser::detect) else {
237 return true;
238 };
239 match self.rules.iter().find(|(rule, _)| *rule == browser) {
240 Some((_, Some(minimum))) => version >= *minimum,
241 Some((_, None)) => false,
242 None => true,
243 }
244 }
245
246 fn refusal(&self) -> Response {
247 let mut response = (StatusCode::NOT_ACCEPTABLE, self.page.to_string()).into_response();
248 response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
249 response
250 }
251}
252
253impl<S> Layer<S> for AllowBrowser {
254 type Service = AllowBrowserService<S>;
255
256 fn layer(&self, inner: S) -> Self::Service {
257 AllowBrowserService { inner, policy: Arc::new(self.clone()) }
258 }
259}
260
261/// The service built by the [`AllowBrowser`] layer.
262///
263/// # Examples
264///
265/// ```
266/// use axum::{Router, routing::get};
267/// use ocre::security::AllowBrowser;
268///
269/// // Built by `.layer(AllowBrowser::modern())`; never named in app code.
270/// let app: Router = Router::new().route("/", get(|| async { "home" })).layer(AllowBrowser::modern());
271/// # let _ = app;
272/// ```
273#[derive(Debug, Clone)]
274pub struct AllowBrowserService<S> {
275 inner: S,
276 policy: Arc<AllowBrowser>,
277}
278
279impl<S> Service<Request> for AllowBrowserService<S>
280where
281 S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static,
282 S::Future: Send + 'static,
283{
284 type Response = Response;
285 type Error = Infallible;
286 type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
287
288 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
289 self.inner.poll_ready(cx)
290 }
291
292 fn call(&mut self, req: Request) -> Self::Future {
293 let user_agent = req.headers().get(header::USER_AGENT).and_then(|value| value.to_str().ok());
294 if !self.policy.allows(user_agent) {
295 let refusal = self.policy.refusal();
296 return Box::pin(async move { Ok(refusal) });
297 }
298 Box::pin(self.inner.call(req))
299 }
300}
301
302#[cfg(test)]
303#[path = "../../tests/security/browser.rs"]
304mod tests;