// SPDX-License-Identifier: AGPL-3.0-or-later use crate::config::{AppProxyConfig, CspConfig, CspSource, HttpEndpoint}; use axum::http::HeaderValue; use axum::http::header::InvalidHeaderValue; use base64::{Engine as _, engine::general_purpose::STANDARD}; use sha2::{Digest, Sha256}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct InlineScriptHash(String); impl InlineScriptHash { pub fn of(script_text: &str) -> Self { Self(format!( "'sha256-{}'", STANDARD.encode(Sha256::digest(script_text.as_bytes())) )) } pub fn as_source(&self) -> &str { &self.0 } } pub fn inline_script_hashes(document: &str) -> Vec { let mut hashes: Vec = Vec::new(); let mut rest = document; while let Some((attributes, text, after)) = next_script_element(rest) { rest = after; if has_src_attribute(attributes) { continue; } let hash = InlineScriptHash::of(text); if !hashes.contains(&hash) { hashes.push(hash); } } hashes } fn next_script_element(html: &str) -> Option<(&str, &str, &str)> { let mut offset = 0; loop { let start = offset + find_ignoring_ascii_case(&html[offset..], "' && boundary != b'/' && !boundary.is_ascii_whitespace() { offset = name_end; continue; } let tag_end = name_end + html[name_end..].find('>')?; let text_start = tag_end + 1; let text_end = text_start + find_ignoring_ascii_case(&html[text_start..], "') .map_or(html.len(), |index| text_end + index + 1); return Some(( &html[name_end..tag_end], &html[text_start..text_end], &html[close_end..], )); } } fn find_ignoring_ascii_case(haystack: &str, needle: &str) -> Option { haystack .as_bytes() .windows(needle.len()) .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) } fn has_src_attribute(attributes: &str) -> bool { attributes .split(|c: char| c.is_ascii_whitespace() || c == '/') .any(|token| { token .split('=') .next() .is_some_and(|name| name.eq_ignore_ascii_case("src")) }) } #[derive(Clone, Debug, Default)] pub struct RuntimeCspSources { pub static_cdn_endpoint: Option, pub media_endpoint: Option, pub s3_public_endpoint: Option, pub s3_uploads_endpoint: Option, } const FRAME_SOURCES: &[&str] = &[ "https://www.youtube.com/embed/", "https://www.youtube.com/s/player/", ]; const IMAGE_SOURCES: &[&str] = &[ "https://*.fluxer.app", "https://fluxer.app", "https://i.ytimg.com", "https://*.youtube.com", "https://*.fluxer.media", "https://fluxer.media", ]; const MEDIA_SOURCES: &[&str] = &[ "https://*.fluxer.app", "https://fluxer.app", "https://*.youtube.com", "https://*.fluxer.media", "https://fluxer.media", ]; const SCRIPT_SOURCES: &[&str] = &["https://*.fluxer.app"]; const STYLE_SOURCES: &[&str] = &[ "https://*.fluxer.app", "https://fonts.googleapis.com", "https://api.fonts.coollabs.io", ]; const FONT_SOURCES: &[&str] = &[ "https://*.fluxer.app", "https://fonts.gstatic.com", "https://api.fonts.coollabs.io", ]; const CONNECT_SOURCES: &[&str] = &[ "https://*.fluxer.app", "wss://*.fluxer.app", "https://*.fluxer.media", "wss://*.fluxer.media", "https://fluxer-uploads.ewr1.vultrobjects.com", "https://fluxerstatus.com", "https://fluxer.media", ]; const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"]; const MANIFEST_SOURCES: &[&str] = &["https://*.fluxer.app"]; #[derive(Debug)] pub enum CspCompileError { InvalidAssetPolicy(InvalidHeaderValue), InvalidSpaPolicy(InvalidHeaderValue), } impl std::fmt::Display for CspCompileError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidAssetPolicy(_) => { formatter.write_str("the asset content security policy is not a valid header value") } Self::InvalidSpaPolicy(_) => { formatter.write_str("the SPA content security policy is not a valid header value") } } } } impl std::error::Error for CspCompileError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::InvalidAssetPolicy(source) | Self::InvalidSpaPolicy(source) => Some(source), } } } #[derive(Clone, Debug)] pub struct CompiledCspPolicy { config: CspConfig, runtime_sources: RuntimeCspSources, asset: HeaderValue, } #[derive(Clone, Debug)] pub struct CspHeaderValues { pub enforced: HeaderValue, pub report_only: Option, } impl CompiledCspPolicy { pub fn from_config(config: &AppProxyConfig) -> Result { Self::compile( config.csp.clone(), &RuntimeCspSources { static_cdn_endpoint: config.static_cdn_endpoint.clone(), media_endpoint: config.media_endpoint.clone(), s3_public_endpoint: config.s3_public_endpoint.clone(), s3_uploads_endpoint: config.s3_uploads_endpoint.clone(), }, ) } pub fn compile( config: CspConfig, configured_sources: &RuntimeCspSources, ) -> Result { let asset_sources = RuntimeCspSources { static_cdn_endpoint: configured_sources.static_cdn_endpoint.clone(), ..RuntimeCspSources::default() }; let asset = HeaderValue::from_str(&build_asset_csp(&config, &asset_sources)) .map_err(CspCompileError::InvalidAssetPolicy)?; HeaderValue::from_str(&build_csp( &config, &[InlineScriptHash::of("")], configured_sources, &[], )) .map_err(CspCompileError::InvalidSpaPolicy)?; Ok(Self { config, runtime_sources: configured_sources.clone(), asset, }) } pub fn asset_header(&self) -> HeaderValue { self.asset.clone() } pub fn spa_headers( &self, script_hashes: &[InlineScriptHash], branding_image_origins: &[HttpEndpoint], ) -> CspHeaderValues { let enforced = HeaderValue::from_str(&build_csp( &self.config, script_hashes, &self.runtime_sources, branding_image_origins, )) .expect( "every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \ policy built from them is always a valid header value", ); CspHeaderValues { enforced, report_only: None, } } } fn build_csp( config: &CspConfig, script_hashes: &[InlineScriptHash], runtime_sources: &RuntimeCspSources, branding_image_origins: &[HttpEndpoint], ) -> String { build_csp_directives( config, Some(script_hashes), runtime_sources, branding_image_origins, ) .join("; ") } fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String { build_csp_directives(config, None, runtime_sources, &[]).join("; ") } fn build_csp_directives( config: &CspConfig, script_hashes: Option<&[InlineScriptHash]>, runtime_sources: &RuntimeCspSources, branding_image_origins: &[HttpEndpoint], ) -> Vec { let mut directives = Vec::with_capacity(14); let mut default = vec!["'self'".to_owned()]; extend_from(&mut default, &config.extra_default_src, &[]); directives.push(format!("default-src {}", default.join(" "))); let mut script = vec![ "'self'".to_owned(), "'wasm-unsafe-eval'".to_owned(), "blob:".to_owned(), ]; if let Some(hashes) = script_hashes { script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned())); } extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES); extend_runtime_sources(&mut script, runtime_sources, true, false); directives.push(format!("script-src {}", script.join(" "))); let mut style = vec!["'self'".to_owned(), "'unsafe-inline'".to_owned()]; extend_from(&mut style, &config.extra_style_src, STYLE_SOURCES); extend_runtime_sources(&mut style, runtime_sources, true, true); directives.push(format!("style-src {}", style.join(" "))); let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES); extend_runtime_sources(&mut img, runtime_sources, true, true); for origin in branding_image_origins { push_endpoint_source(&mut img, Some(origin)); } directives.push(format!("img-src {}", img.join(" "))); let mut media = vec!["'self'".to_owned(), "blob:".to_owned()]; extend_from(&mut media, &config.extra_media_src, MEDIA_SOURCES); extend_runtime_sources(&mut media, runtime_sources, true, true); directives.push(format!("media-src {}", media.join(" "))); let mut font = vec!["'self'".to_owned(), "data:".to_owned()]; extend_from(&mut font, &config.extra_font_src, FONT_SOURCES); extend_runtime_sources(&mut font, runtime_sources, true, true); directives.push(format!("font-src {}", font.join(" "))); let mut connect = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; extend_from(&mut connect, &config.extra_connect_src, CONNECT_SOURCES); extend_runtime_sources(&mut connect, runtime_sources, true, true); extend_runtime_s3_sources(&mut connect, runtime_sources); directives.push(format!("connect-src {}", connect.join(" "))); let mut frame = vec!["'self'".to_owned()]; extend_from(&mut frame, &config.extra_frame_src, FRAME_SOURCES); directives.push(format!("frame-src {}", frame.join(" "))); let mut worker = vec!["'self'".to_owned(), "blob:".to_owned()]; extend_from(&mut worker, &config.extra_worker_src, WORKER_SOURCES); extend_runtime_sources(&mut worker, runtime_sources, true, false); directives.push(format!("worker-src {}", worker.join(" "))); let mut manifest = vec!["'self'".to_owned()]; extend_from(&mut manifest, &config.extra_manifest_src, MANIFEST_SOURCES); extend_runtime_sources(&mut manifest, runtime_sources, true, false); directives.push(format!("manifest-src {}", manifest.join(" "))); directives.push("object-src 'none'".to_owned()); directives.push("base-uri 'self'".to_owned()); directives.push("frame-ancestors 'none'".to_owned()); if let Some(report_uri) = &config.report_uri { directives.push(format!("report-uri {report_uri}")); } directives } fn extend_runtime_sources( target: &mut Vec, runtime_sources: &RuntimeCspSources, include_static: bool, include_media: bool, ) { if include_static { push_endpoint_source(target, runtime_sources.static_cdn_endpoint.as_ref()); } if include_media { push_endpoint_source(target, runtime_sources.media_endpoint.as_ref()); } } fn push_endpoint_source(target: &mut Vec, endpoint: Option<&HttpEndpoint>) { let Some(endpoint) = endpoint else { return; }; let source = endpoint.csp_origin(); if target.iter().any(|existing| existing == source) { return; } target.push(source.to_owned()); } fn extend_runtime_s3_sources(target: &mut Vec, runtime_sources: &RuntimeCspSources) { push_endpoint_source(target, runtime_sources.s3_public_endpoint.as_ref()); push_endpoint_source(target, runtime_sources.s3_uploads_endpoint.as_ref()); } fn extend_from(target: &mut Vec, extra: &[CspSource], defaults: &[&str]) { for source in defaults .iter() .copied() .chain(extra.iter().map(CspSource::as_str)) { if target.iter().any(|existing| existing == source) { continue; } target.push(source.to_owned()); } } #[cfg(test)] mod tests { use super::*; fn hash_of(text: &str) -> InlineScriptHash { InlineScriptHash::of(text) } #[test] fn an_inline_script_hash_is_the_base64_sha256_of_the_exact_script_text() { assert_eq!( hash_of("alert('Hello, world.');").as_source(), "'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='" ); assert_eq!( hash_of("").as_source(), "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" ); } #[test] fn every_inline_script_is_hashed_and_external_scripts_are_not() { let document = concat!( "", "", "", "", "not a script", "", ); assert_eq!( inline_script_hashes(document), vec![ hash_of("first()"), hash_of("second()"), hash_of("\nthird()\n") ] ); } #[test] fn an_inline_script_repeated_verbatim_is_granted_once() { let document = ""; assert_eq!(inline_script_hashes(document), vec![hash_of("same()")]); } #[test] fn an_unterminated_script_is_never_granted() { assert!(inline_script_hashes("