diff --git a/crates/protocols/src/swift/staticweb.rs b/crates/protocols/src/swift/staticweb.rs index 9ee550a50..c6f252ea3 100644 --- a/crates/protocols/src/swift/staticweb.rs +++ b/crates/protocols/src/swift/staticweb.rs @@ -69,6 +69,7 @@ use super::{SwiftError, SwiftResult, container, object}; use axum::http::{Response, StatusCode}; +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use rustfs_credentials::Credentials; use s3s::Body; use tracing::debug; @@ -77,6 +78,58 @@ const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SWIFT_STATICWEB: &str = "swift_staticweb"; const EVENT_SWIFT_STATICWEB_STATE: &str = "swift_staticweb_state"; +/// Restrictive Content-Security-Policy for auto-generated listing pages. +/// +/// `default-src 'none'` disables script execution (the primary XSS defense); +/// `style-src` permits the page's own stylesheet plus the inline default CSS. +const LISTING_CSP: &str = + "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'"; + +/// Characters that must be percent-encoded when a dynamic value is placed into +/// a URL path inside a double-quoted HTML `href` attribute. +/// +/// Path separators (`/`) are intentionally preserved. Every character that is +/// significant to HTML attribute parsing (`"`, `<`, `>`, `&`, `'`) or to URL +/// parsing (`#`, `?`, `%`, space, backtick, braces) is encoded, so the result +/// is safe in both the URL and the surrounding HTML attribute context. +const HREF_PATH_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'&') + .add(b'\'') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +/// HTML-entity-encode a dynamic value for safe interpolation into HTML text or +/// double-quoted attribute contexts. Neutralizes stored/reflected XSS. +fn html_escape(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for c in input.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// Percent-encode a dynamic value for use as a URL path inside an `href` +/// attribute. The output contains no HTML-significant characters, so it is +/// safe to interpolate directly into a double-quoted attribute. +fn href_encode(input: &str) -> String { + utf8_percent_encode(input, HREF_PATH_ENCODE_SET).to_string() +} + /// Static website configuration for a container #[derive(Debug, Clone, Default)] pub struct StaticWebConfig { @@ -247,7 +300,7 @@ pub fn resolve_path(path: &str, config: &StaticWebConfig) -> (String, bool, bool /// Generate breadcrumb navigation HTML fn generate_breadcrumbs(path: &str, container: &str) -> String { let mut html = String::from("
\n"); - html.push_str(&format!(" /{}\n", container)); + html.push_str(&format!(" /{}\n", html_escape(container))); if !path.is_empty() { let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); @@ -257,9 +310,9 @@ fn generate_breadcrumbs(path: &str, container: &str) -> String { current_path.push_str(part); if i < parts.len() - 1 { current_path.push('/'); - html.push_str(&format!(" / {}\n", current_path, part)); + html.push_str(&format!(" / {}\n", href_encode(¤t_path), html_escape(part))); } else { - html.push_str(&format!(" / {}\n", part)); + html.push_str(&format!(" / {}\n", html_escape(part))); } } } @@ -277,10 +330,10 @@ pub fn generate_directory_listing( ) -> String { let mut html = String::from("\n\n\n"); html.push_str(" \n"); - html.push_str(&format!(" Index of /{}\n", path)); + html.push_str(&format!(" Index of /{}\n", html_escape(path))); if let Some(css) = css_path { - html.push_str(&format!(" \n", css)); + html.push_str(&format!(" \n", href_encode(css))); } else { // Default inline CSS html.push_str("