Skip to main content

ocre/
security.rs

1//! Security helpers beyond what [`serve`](crate::serve) always does: policies, rate limits, safe redirects, HTML cleaning.
2//!
3//! `serve` already applies security headers, host authorization
4//! ([`ALLOWED_HOSTS`](crate::ALLOWED_HOSTS)), CORS, cross-site request
5//! (CSRF) protection and encrypted session cookies. This module adds what an
6//! app opts into:
7//!
8//! | Item | Rails equivalent |
9//! |---|---|
10//! | [`ContentSecurityPolicy`], [`CspNonce`] | `content_security_policy`, `content_security_policy_nonce` |
11//! | [`PermissionsPolicy`] | `permissions_policy` |
12//! | [`rate_limit`] | `rate_limit to:, within:, by:` |
13//! | [`url_from`] | `url_from`, `redirect_to ... allow_other_host: false` |
14//! | [`sanitize`], [`sanitize_with`], [`strip_tags`] | `sanitize`, `strip_tags` |
15//! | [`json_escape`], [`escape_javascript`] | `json_escape`, `escape_javascript` |
16//! | [`filter_parameters`], [`filter_json`], [`FILTERED_PARAMETERS`] | `filter_parameters` |
17//! | [`BasicAuth`] | `http_basic_authenticate_with` |
18//! | [`AllowBrowser`] | `allow_browser versions: :modern` |
19//!
20//! ```
21//! use axum::{Router, routing::get};
22//! use ocre::security::{ContentSecurityPolicy, PermissionsPolicy, SELF};
23//!
24//! let app: Router = Router::new()
25//!     .route("/", get(|| async { "home" }))
26//!     .layer(ContentSecurityPolicy::new().default_src(&[SELF]))
27//!     .layer(PermissionsPolicy::new().deny(&["camera", "microphone"]));
28//! # let _ = app;
29//! ```
30
31mod browser;
32mod html;
33mod policy;
34
35use axum::{
36    extract::FromRequestParts,
37    http::{HeaderValue, StatusCode, Uri, header, request::Parts},
38    response::{IntoResponse, Response},
39};
40use base64::{Engine as _, engine::general_purpose::STANDARD};
41use serde_json::Value;
42
43pub use self::{
44    browser::{AllowBrowser, AllowBrowserService, Browser},
45    html::{SANITIZE_ATTRIBUTES, SANITIZE_TAGS, escape_javascript, json_escape, sanitize, sanitize_with, strip_tags},
46    policy::{
47        BLOB, ContentSecurityPolicy, CspNonce, DATA, HTTPS, NONCE, NONE, PermissionsPolicy, PolicyService, SELF,
48        STRICT_DYNAMIC, UNSAFE_EVAL, UNSAFE_INLINE,
49    },
50};
51pub use crate::runtime::security::rate_limit;
52use crate::token::constant_time_eq;
53
54/// The URL to redirect to when `candidate` points inside this app, else `None` (Rails' `url_from`).
55///
56/// Use it for every redirect target that comes from the request (a
57/// `return_to` parameter, a `Referer`), so the app cannot be used to send
58/// users to another site (open redirect). Accepted:
59///
60/// - a path: `/account?tab=keys` (but not `//evil.example`, which browsers
61///   read as another host, nor `/\evil.example`);
62/// - an absolute `http(s)` URL whose host (and port) is the request's own
63///   host: it is returned as its path and query.
64///
65/// Anything with control characters (CR, LF, tab: header injection) or
66/// backslashes is refused. `uri` is the request's URI (the `Uri`
67/// extractor); on Workers it includes the host. No binding call.
68///
69/// # Examples
70///
71/// ```
72/// use axum::http::Uri;
73/// use ocre::security::url_from;
74///
75/// let request: Uri = "https://app.example.com/login".parse().unwrap();
76/// assert_eq!(url_from(&request, "/account?tab=keys").as_deref(), Some("/account?tab=keys"));
77/// assert_eq!(url_from(&request, "https://app.example.com/posts/1").as_deref(), Some("/posts/1"));
78/// assert_eq!(url_from(&request, "https://evil.example/"), None);
79/// assert_eq!(url_from(&request, "//evil.example"), None);
80/// assert_eq!(url_from(&request, "/\r\nSet-Cookie: x=1"), None);
81///
82/// // In a handler: `Redirect::to(&url_from(&uri, &form.return_to).unwrap_or_else(|| "/".to_owned()))`.
83/// ```
84pub fn url_from(uri: &Uri, candidate: &str) -> Option<String> {
85    if candidate.is_empty() || candidate.chars().any(|c| c.is_control() || c == '\\') {
86        return None;
87    }
88    if candidate.starts_with('/') {
89        return (!candidate.starts_with("//")).then(|| candidate.to_owned());
90    }
91    let target: Uri = candidate.parse().ok()?;
92    let same_scheme = matches!(target.scheme_str(), Some("http" | "https"));
93    let (Some(target_host), Some(own)) = (target.authority(), uri.authority()) else { return None };
94    let same_host = target_host.as_str().eq_ignore_ascii_case(own.as_str());
95    (same_scheme && same_host).then(|| target.path_and_query().map_or("/", |path| path.as_str()).to_owned())
96}
97
98/// Parameter name fragments whose values [`filter_parameters`] and [`filter_json`] hide.
99///
100/// Rails' default `filter_parameters`: a parameter is hidden when its name,
101/// lowercased, contains one of these (`password`, `password_confirmation`,
102/// `api_key`, `reset_token`...).
103///
104/// # Examples
105///
106/// ```
107/// assert!(ocre::security::FILTERED_PARAMETERS.contains(&"passw"));
108/// ```
109pub const FILTERED_PARAMETERS: &[&str] =
110    &["passw", "email", "secret", "token", "_key", "crypt", "salt", "certificate", "otp", "ssn", "cvv", "cvc"];
111
112/// The replacement for hidden values: `[FILTERED]`.
113const FILTERED: &str = "[FILTERED]";
114
115fn is_filtered(name: &str) -> bool {
116    let name = name.to_ascii_lowercase();
117    FILTERED_PARAMETERS.iter().any(|fragment| name.contains(fragment))
118}
119
120/// A query string or form body with sensitive values replaced by `[FILTERED]` (Rails' `filter_parameters`).
121///
122/// Log requests through it: `password=hunter2&next=/` becomes
123/// `password=[FILTERED]&next=/`. Names are matched against
124/// [`FILTERED_PARAMETERS`] after percent-decoding. No binding call.
125///
126/// # Examples
127///
128/// ```
129/// use ocre::security::filter_parameters;
130///
131/// assert_eq!(
132///     filter_parameters("email=ada%40example.com&password=hunter2&page=2"),
133///     "email=[FILTERED]&password=[FILTERED]&page=2"
134/// );
135/// assert_eq!(filter_parameters("user%5Bpassword%5D=x"), "user%5Bpassword%5D=[FILTERED]");
136/// ```
137pub fn filter_parameters(query: &str) -> String {
138    let pairs: Vec<String> = query
139        .split('&')
140        .map(|pair| {
141            let (name, _) = pair.split_once('=').unwrap_or((pair, ""));
142            // One pair decodes to one name; collecting avoids a fallback for an impossible empty result.
143            let decoded: String = serde_urlencoded::from_str::<Vec<(String, String)>>(&format!("{name}="))
144                .unwrap_or_default()
145                .into_iter()
146                .map(|(name, _)| name)
147                .collect();
148            if pair.contains('=') && is_filtered(&decoded) { format!("{name}={FILTERED}") } else { pair.to_owned() }
149        })
150        .collect();
151    pairs.join("&")
152}
153
154/// A JSON value with sensitive values replaced by `"[FILTERED]"`, at any depth.
155///
156/// For logging JSON request bodies; keys are matched like
157/// [`filter_parameters`]. No binding call.
158///
159/// # Examples
160///
161/// ```
162/// use ocre::security::filter_json;
163/// use serde_json::json;
164///
165/// let body = json!({"user": {"email": "ada@example.com", "name": "Ada"}, "api_key": "k"});
166/// assert_eq!(filter_json(&body), json!({"user": {"email": "[FILTERED]", "name": "Ada"}, "api_key": "[FILTERED]"}));
167/// ```
168pub fn filter_json(value: &Value) -> Value {
169    match value {
170        Value::Object(map) => Value::Object(
171            map.iter()
172                .map(|(key, value)| {
173                    let value = if is_filtered(key) { Value::from(FILTERED) } else { filter_json(value) };
174                    (key.clone(), value)
175                })
176                .collect(),
177        ),
178        Value::Array(items) => Value::Array(items.iter().map(filter_json).collect()),
179        other => other.clone(),
180    }
181}
182
183/// HTTP Basic credentials from `Authorization: Basic ...`, as an extractor (Rails' `http_basic_authenticate_with`).
184///
185/// Rejects requests without valid Basic credentials with
186/// [`BasicAuth::challenge`]: `401` and `WWW-Authenticate: Basic`, so the
187/// browser asks for a user name and password. Check them with
188/// [`matches`](Self::matches) (constant time) against Worker secrets, and
189/// answer [`challenge`](Self::challenge) when they are wrong. Browsers resend
190/// the credentials on every request over HTTPS; use it for a staging site or
191/// an internal page, not for user accounts (`ocre g auth`).
192///
193/// # Examples
194///
195/// ```no_run
196/// use axum::{extract::State, response::{IntoResponse, Response}};
197/// use ocre::{Ctx, Result, security::BasicAuth};
198///
199/// async fn admin(State(ctx): State<Ctx>, auth: BasicAuth) -> Result<Response> {
200///     let password = ctx.secret("ADMIN_PASSWORD").await?;
201///     if !auth.matches("admin", &password) {
202///         return Ok(BasicAuth::challenge());
203///     }
204///     Ok("Welcome, admin".into_response())
205/// }
206/// # let _ = admin;
207/// ```
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct BasicAuth {
210    /// The user name the client sent.
211    pub username: String,
212    /// The password the client sent.
213    pub password: String,
214}
215
216impl BasicAuth {
217    /// Whether the credentials are `username` and `password`, compared in constant time.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use ocre::security::BasicAuth;
223    ///
224    /// let auth = BasicAuth { username: "admin".into(), password: "s3cret".into() };
225    /// assert!(auth.matches("admin", "s3cret"));
226    /// assert!(!auth.matches("admin", "guess"));
227    /// ```
228    pub fn matches(&self, username: &str, password: &str) -> bool {
229        // Both comparisons run, so timing does not reveal which one failed.
230        let user = constant_time_eq(self.username.as_bytes(), username.as_bytes());
231        let pass = constant_time_eq(self.password.as_bytes(), password.as_bytes());
232        user & pass
233    }
234
235    /// `401 Unauthorized` with `WWW-Authenticate: Basic realm="Application"`: the browser asks again.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// let response = ocre::security::BasicAuth::challenge();
241    /// assert_eq!(response.status(), 401);
242    /// assert_eq!(response.headers()["www-authenticate"], r#"Basic realm="Application", charset="UTF-8""#);
243    /// ```
244    pub fn challenge() -> Response {
245        let challenge = HeaderValue::from_static(r#"Basic realm="Application", charset="UTF-8""#);
246        (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, challenge)], "HTTP Basic: Access denied.\n")
247            .into_response()
248    }
249
250    fn from_header(value: Option<&HeaderValue>) -> Option<Self> {
251        let value = value?.to_str().ok()?;
252        let (scheme, encoded) = value.split_once(' ')?;
253        if !scheme.eq_ignore_ascii_case("basic") {
254            return None;
255        }
256        let decoded = String::from_utf8(STANDARD.decode(encoded.trim()).ok()?).ok()?;
257        let (username, password) = decoded.split_once(':')?;
258        Some(Self { username: username.to_owned(), password: password.to_owned() })
259    }
260}
261
262impl<S: Sync> FromRequestParts<S> for BasicAuth {
263    type Rejection = Response;
264
265    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Response> {
266        Self::from_header(parts.headers.get(header::AUTHORIZATION)).ok_or_else(Self::challenge)
267    }
268}
269
270#[cfg(test)]
271#[path = "../tests/security.rs"]
272mod tests;