From 562819be094740a9dc054255b390d3bc1fa19bf8 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 15 Aug 2026 21:32:16 +0200 Subject: [PATCH] fix(app-proxy): allow extra CSP sources from the environment (#1652) --- deploy/self-hosting/.env.example | 8 ++++ deploy/self-hosting/docker-compose.yml | 11 +++++ fluxer_app_proxy/src/config.rs | 61 ++++++++++++++++++++------ fluxer_app_proxy/src/csp.rs | 46 ++++++++++++------- 4 files changed, 98 insertions(+), 28 deletions(-) diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index 4ce842b3a..87da3fd7a 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -31,6 +31,14 @@ FLUXER_VAPID_EMAIL=admin@example.com #FLUXER_PASSKEY_RP_NAME=Fluxer #FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com +# Extra Content-Security-Policy sources, appended to the built-in ones. Set these +# only when a browser must reach an origin the defaults do not cover, such as a +# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several +# sources with spaces or commas. +#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881 +#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com +#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com + LIVEKIT_API_KEY=fluxer LIVEKIT_API_SECRET=CHANGE_ME diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index a72868fc9..ef54e7b8e 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -285,6 +285,17 @@ services: DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer PUBLIC_BOOTSTRAP_API_ENDPOINT: /api PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api + FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-} + FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-} + FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-} + FLUXER_CSP_EXTRA_MEDIA_SRC: ${FLUXER_CSP_EXTRA_MEDIA_SRC:-} + FLUXER_CSP_EXTRA_FONT_SRC: ${FLUXER_CSP_EXTRA_FONT_SRC:-} + FLUXER_CSP_EXTRA_SCRIPT_SRC: ${FLUXER_CSP_EXTRA_SCRIPT_SRC:-} + FLUXER_CSP_EXTRA_STYLE_SRC: ${FLUXER_CSP_EXTRA_STYLE_SRC:-} + FLUXER_CSP_EXTRA_FRAME_SRC: ${FLUXER_CSP_EXTRA_FRAME_SRC:-} + FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-} + FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-} + FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-} depends_on: api: {condition: service_healthy} caddy: {condition: service_started} diff --git a/fluxer_app_proxy/src/config.rs b/fluxer_app_proxy/src/config.rs index b7cbd1b0c..5cd016c83 100644 --- a/fluxer_app_proxy/src/config.rs +++ b/fluxer_app_proxy/src/config.rs @@ -87,19 +87,52 @@ impl ReleaseChannel { #[derive(Clone, Debug, Default)] pub struct CspConfig { - pub default_src: Option>, - pub connect_src: Option>, - pub img_src: Option>, - pub media_src: Option>, - pub font_src: Option>, - pub script_src: Option>, - pub style_src: Option>, - pub frame_src: Option>, - pub worker_src: Option>, - pub manifest_src: Option>, + pub extra_default_src: Option>, + pub extra_connect_src: Option>, + pub extra_img_src: Option>, + pub extra_media_src: Option>, + pub extra_font_src: Option>, + pub extra_script_src: Option>, + pub extra_style_src: Option>, + pub extra_frame_src: Option>, + pub extra_worker_src: Option>, + pub extra_manifest_src: Option>, pub report_uri: Option, } +impl CspConfig { + pub fn from_env() -> Self { + Self { + extra_default_src: read_csp_sources("FLUXER_CSP_EXTRA_DEFAULT_SRC"), + extra_connect_src: read_csp_sources("FLUXER_CSP_EXTRA_CONNECT_SRC"), + extra_img_src: read_csp_sources("FLUXER_CSP_EXTRA_IMG_SRC"), + extra_media_src: read_csp_sources("FLUXER_CSP_EXTRA_MEDIA_SRC"), + extra_font_src: read_csp_sources("FLUXER_CSP_EXTRA_FONT_SRC"), + extra_script_src: read_csp_sources("FLUXER_CSP_EXTRA_SCRIPT_SRC"), + extra_style_src: read_csp_sources("FLUXER_CSP_EXTRA_STYLE_SRC"), + extra_frame_src: read_csp_sources("FLUXER_CSP_EXTRA_FRAME_SRC"), + extra_worker_src: read_csp_sources("FLUXER_CSP_EXTRA_WORKER_SRC"), + extra_manifest_src: read_csp_sources("FLUXER_CSP_EXTRA_MANIFEST_SRC"), + report_uri: cfg::non_empty_env("FLUXER_CSP_REPORT_URI"), + } + } +} + +fn read_csp_sources(name: &str) -> Option> { + let sources: Vec = cfg::read_env(name, "") + .split([',', ' ', '\t', '\n']) + .map(str::trim) + .filter(|source| !source.is_empty()) + .map(str::to_owned) + .collect(); + + if sources.is_empty() { + None + } else { + Some(sources) + } +} + impl AppProxyConfig { pub fn from_env() -> Self { let release_channel = ReleaseChannel::from_env_value(&cfg::read_env_preferred( @@ -169,7 +202,7 @@ impl AppProxyConfig { bootstrap_api_public_endpoint: cfg::non_empty_env( "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", ), - csp: CspConfig::default(), + csp: CspConfig::from_env(), geoip_source, geoip_s3_config, trust_client_ip_header: cfg::read_bool_env( @@ -305,9 +338,11 @@ mod tests { } #[test] - fn csp_config_default_has_no_overrides() { + fn csp_config_default_has_no_extra_sources() { let c = CspConfig::default(); - assert!(c.default_src.is_none() && c.script_src.is_none() && c.report_uri.is_none()); + assert!( + c.extra_default_src.is_none() && c.extra_script_src.is_none() && c.report_uri.is_none() + ); } #[test] diff --git a/fluxer_app_proxy/src/csp.rs b/fluxer_app_proxy/src/csp.rs index 357b30414..5bf96f6a4 100644 --- a/fluxer_app_proxy/src/csp.rs +++ b/fluxer_app_proxy/src/csp.rs @@ -97,7 +97,7 @@ fn build_csp_directives( let mut directives = Vec::with_capacity(14); let mut default = vec!["'self'".to_owned()]; - extend_from(&mut default, config.default_src.as_deref(), &[]); + extend_from(&mut default, config.extra_default_src.as_deref(), &[]); directives.push(format!("default-src {}", default.join(" "))); let mut script = vec![ @@ -108,17 +108,21 @@ fn build_csp_directives( if let Some(n) = nonce { script.insert(1, format!("'nonce-{n}'")); } - extend_from(&mut script, config.script_src.as_deref(), SCRIPT_SOURCES); + extend_from( + &mut script, + config.extra_script_src.as_deref(), + 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.style_src.as_deref(), STYLE_SOURCES); + extend_from(&mut style, config.extra_style_src.as_deref(), 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.img_src.as_deref(), IMAGE_SOURCES); + extend_from(&mut img, config.extra_img_src.as_deref(), IMAGE_SOURCES); extend_runtime_sources(&mut img, runtime_sources, true, true); for origin in &runtime_sources.branding_image_origins { push_endpoint_source(&mut img, Some(origin)); @@ -126,34 +130,42 @@ fn build_csp_directives( directives.push(format!("img-src {}", img.join(" "))); let mut media = vec!["'self'".to_owned(), "blob:".to_owned()]; - extend_from(&mut media, config.media_src.as_deref(), MEDIA_SOURCES); + extend_from(&mut media, config.extra_media_src.as_deref(), 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.font_src.as_deref(), FONT_SOURCES); + extend_from(&mut font, config.extra_font_src.as_deref(), 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(), "data:".to_owned()]; - extend_from(&mut connect, config.connect_src.as_deref(), CONNECT_SOURCES); + extend_from( + &mut connect, + config.extra_connect_src.as_deref(), + 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.frame_src.as_deref(), FRAME_SOURCES); + extend_from(&mut frame, config.extra_frame_src.as_deref(), FRAME_SOURCES); directives.push(format!("frame-src {}", frame.join(" "))); let mut worker = vec!["'self'".to_owned(), "blob:".to_owned()]; - extend_from(&mut worker, config.worker_src.as_deref(), WORKER_SOURCES); + extend_from( + &mut worker, + config.extra_worker_src.as_deref(), + 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.manifest_src.as_deref(), + config.extra_manifest_src.as_deref(), MANIFEST_SOURCES, ); extend_runtime_sources(&mut manifest, runtime_sources, true, false); @@ -243,11 +255,15 @@ fn s3_uploads_bucket_origin(runtime_sources: &RuntimeCspSources) -> Option, overrides: Option<&[String]>, defaults: &[&str]) { - if let Some(sources) = overrides { - target.extend(sources.iter().cloned()); - } else { - target.extend(defaults.iter().map(|s| (*s).to_owned())); +fn extend_from(target: &mut Vec, extra: Option<&[String]>, defaults: &[&str]) { + target.extend(defaults.iter().map(|s| (*s).to_owned())); + + for source in extra.into_iter().flatten() { + let source = source.trim(); + if source.is_empty() || target.iter().any(|existing| existing == source) { + continue; + } + target.push(source.to_owned()); } }