Skip to main content

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;