changes in headers

This commit is contained in:
Mateusz Gruszczyński
2026-08-02 01:21:31 +02:00
parent f1ef3c5a11
commit ede6d1de92
3 changed files with 162 additions and 52 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.18" version = "0.2.19"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.18" version = "0.2.19"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+160 -50
View File
@@ -12,7 +12,7 @@ mod pages;
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
http::{HeaderName, HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next}, middleware::{self, Next},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
@@ -212,21 +212,9 @@ pub fn router(
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024), 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(TraceLayer::new_for_http())
.layer(middleware::from_fn(require_csrf_token)) .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) .with_state(state)
} }
@@ -248,42 +236,164 @@ async fn require_csrf_token(request: Request, next: Next) -> Response {
next.run(request).await next.run(request).await
} }
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { async fn apply_response_header_policy(request: Request, next: Next) -> Response {
let path = request.uri().path(); let policy = response_header_policy(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"
);
let mut response = next.run(request).await; let mut response = next.run(request).await;
apply_response_headers(policy, response.headers_mut());
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=()",
));
}
response 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"));
}
}