diff --git a/Cargo.lock b/Cargo.lock index 7f623e1..907e5a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.18" +version = "0.2.19" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 4d5fa8b..6de6500 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.18" +version = "0.2.19" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/app/mod.rs b/src/app/mod.rs index 267d075..0b9c818 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,7 +12,7 @@ mod pages; use axum::{ Json, Router, extract::{DefaultBodyLimit, Request}, - http::{HeaderName, HeaderValue, Method, StatusCode, header}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, @@ -212,21 +212,9 @@ pub fn router( .layer(DefaultBodyLimit::max( upload_max_size_bytes.saturating_add(1024 * 1024), )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("x-frame-options"), - HeaderValue::from_static("DENY"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("cross-origin-resource-policy"), - HeaderValue::from_static("same-origin"), - )) .layer(TraceLayer::new_for_http()) .layer(middleware::from_fn(require_csrf_token)) - .layer(middleware::from_fn(add_non_asset_security_headers)) + .layer(middleware::from_fn(apply_response_header_policy)) .with_state(state) } @@ -248,42 +236,164 @@ async fn require_csrf_token(request: Request, next: Next) -> Response { next.run(request).await } -async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { - let path = request.uri().path(); - - let is_asset = path.starts_with("/assets/"); - let is_file = path.starts_with("/f/"); - let is_icon = matches!( - path, - "/favicon.svg" | "/favicon.ico" | "/favicon-32.png" | "/apple-touch-icon.png" - ); - +async fn apply_response_header_policy(request: Request, next: Next) -> Response { + let policy = response_header_policy(request.uri().path()); let mut response = next.run(request).await; - - if is_file && is_asset && is_icon { - let headers = response.headers_mut(); - headers.remove("x-frame-options"); - headers.remove("cross-origin-opener-policy"); - headers.remove("cross-origin-resource-policy"); - headers.remove("referrer-policy"); - headers.remove("permissions-policy"); - } else if !is_asset && !is_icon { - let headers = response.headers_mut(); - - headers - .entry("x-content-type-options") - .or_insert(HeaderValue::from_static("nosniff")); - - headers - .entry("referrer-policy") - .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); - - headers - .entry("permissions-policy") - .or_insert(HeaderValue::from_static( - "camera=(), microphone=(), geolocation=(), payment=(), usb=()", - )); - } - + apply_response_headers(policy, response.headers_mut()); response } + +fn apply_response_headers(policy: ResponseHeaderPolicy, headers: &mut HeaderMap) { + match policy { + ResponseHeaderPolicy::StaticAsset => { + headers + .entry(header::X_CONTENT_TYPE_OPTIONS) + .or_insert(HeaderValue::from_static("nosniff")); + } + ResponseHeaderPolicy::Application => { + headers + .entry("x-frame-options") + .or_insert(HeaderValue::from_static("DENY")); + + headers + .entry("cross-origin-opener-policy") + .or_insert(HeaderValue::from_static("same-origin")); + + headers + .entry("cross-origin-resource-policy") + .or_insert(HeaderValue::from_static("same-origin")); + + headers + .entry(header::X_CONTENT_TYPE_OPTIONS) + .or_insert(HeaderValue::from_static("nosniff")); + + headers + .entry("referrer-policy") + .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); + + headers + .entry("permissions-policy") + .or_insert(HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + )); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResponseHeaderPolicy { + Application, + StaticAsset, +} + +fn response_header_policy(path: &str) -> ResponseHeaderPolicy { + if is_asset_path(path) || is_icon_path(path) { + ResponseHeaderPolicy::StaticAsset + } else { + ResponseHeaderPolicy::Application + } +} + +fn is_asset_path(path: &str) -> bool { + path == "/assets" || path.starts_with("/assets/") +} + +fn is_icon_path(path: &str) -> bool { + path == "/icons" + || path.starts_with("/icons/") + || matches!( + path, + "/favicon.svg" | "/favicon.ico" | "/favicon-32.png" | "/apple-touch-icon.png" + ) +} + +#[cfg(test)] +mod tests { + use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy}; + use axum::http::{HeaderMap, HeaderValue, header}; + + #[test] + fn classifies_assets_and_icons_as_static_assets() { + for path in [ + "/assets/app.js", + "/assets", + "/favicon.ico", + "/icons/favicon.svg", + "/icons/missing.svg", + ] { + assert_eq!( + response_header_policy(path), + ResponseHeaderPolicy::StaticAsset + ); + } + } + + #[test] + fn keeps_file_routes_on_the_application_policy() { + for path in [ + "/f", + "/f/token/image.png", + "/files", + "/files/legacy/image.png", + ] { + assert_eq!( + response_header_policy(path), + ResponseHeaderPolicy::Application + ); + } + } + + #[test] + fn classifies_other_routes_as_application() { + for path in ["/", "/api/auth/me", "/static/missing.css", "/unknown"] { + assert_eq!( + response_header_policy(path), + ResponseHeaderPolicy::Application + ); + } + } + + #[test] + fn static_asset_policy_only_adds_nosniff() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=3600"), + ); + + apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers); + + assert_eq!(headers.len(), 2); + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert!(!headers.contains_key("x-frame-options")); + assert!(!headers.contains_key("cross-origin-opener-policy")); + assert!(!headers.contains_key("cross-origin-resource-policy")); + assert!(!headers.contains_key("referrer-policy")); + assert!(!headers.contains_key("permissions-policy")); + } + + #[test] + fn application_policy_preserves_handler_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + + apply_response_headers(ResponseHeaderPolicy::Application, &mut headers); + + assert_eq!( + headers["content-security-policy"], + "default-src 'none'; sandbox" + ); + assert_eq!(headers["x-frame-options"], "DENY"); + assert_eq!(headers["cross-origin-opener-policy"], "same-origin"); + assert_eq!(headers["cross-origin-resource-policy"], "same-origin"); + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert_eq!( + headers["referrer-policy"], + "strict-origin-when-cross-origin" + ); + assert!(headers.contains_key("permissions-policy")); + } +}