diff --git a/CHANGELOG.md b/CHANGELOG.md index 5936209a2..ca94ef3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - EPG Config View - Fixed loading users for WebUI from user DB - Fixed auto EPG for batch inputs -- ContentSecurityPolicy can be enabled +- Content Security Policies configurable via config # 3.1.5 (2025-08-14) - Hot reload for config diff --git a/README.md b/README.md index 9b775882f..eb1d8240c 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,7 @@ log: ### 1.10 `web_ui` - enabled: default is true, if set to false the web_ui is disabled - user_ui_enabled, true or false, for user bouquet editor -- content_security_policy: default false; when true, sends a Content-Security-Policy (CSP) header to help protect against cross-site scripting (XSS). - Not: enabling CSP may block external images/logos unless allowed via the img-src directive. +- content-security-policies: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' nonce-{nonce_b64}`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`. - path is for web_ui path like `/ui` for reverse proxy integration if necessary. - auth for authentication settings - `enabled` can be deactivated if `enabled` is set to `false`. If not set default is `true`. @@ -336,6 +335,19 @@ log: web_ui: enabled: true user_ui_enabled: true + content-security-policies: + enabled: true + custom-attributes: + - "default-src 'self'" + - "script-src 'self' nonce-{nonce_b64}" + - "frame-ancestors 'none'" + - "style-src 'self'" + - "img-src 'self' data:" + - "font-src 'self' data:" + - "connect-src 'self' wss:" + - "object-src 'none'" + - "base-uri 'self'" + - "form-action 'self'" path: auth: enabled: true diff --git a/backend/src/api/endpoints/web_index.rs b/backend/src/api/endpoints/web_index.rs index d86a3f200..4ebad9bc5 100644 --- a/backend/src/api/endpoints/web_index.rs +++ b/backend/src/api/endpoints/web_index.rs @@ -164,10 +164,22 @@ async fn index( let mut builder = axum::response::Response::builder() .header("Content-Type", mime::TEXT_HTML_UTF_8.as_ref()); - if config.web_ui.as_ref().is_some_and(|w| w.content_security_policy) { - builder = builder.header("Content-Security-Policy", - format!("default-src 'self'; script-src 'self' 'unsafe-eval' 'nonce-{nonce_b64}'; style-src 'self' 'nonce-{nonce_b64}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'")); - + if let Some(csp) = config + .web_ui + .as_ref() + .and_then(|w| w.content_security_policies.as_ref()) + .filter(|c| c.enabled) + { + let mut attrs = vec![ + "default-src 'self'".to_string(), + format!("script-src 'self' nonce-{nonce_b64}"), + "frame-ancestors 'none'".to_string(), + ]; + attrs.extend(csp.custom_attributes.iter().cloned()); + for attr in attrs.iter_mut() { + *attr = attr.replace("{nonce_b64}", &nonce_b64); + } + builder = builder.header("Content-Security-Policy", attrs.join("; ")); } return try_unwrap_body!(builder.body(new_content)); } diff --git a/backend/src/model/config/web_ui.rs b/backend/src/model/config/web_ui.rs index 8474783c6..e9abd5188 100644 --- a/backend/src/model/config/web_ui.rs +++ b/backend/src/model/config/web_ui.rs @@ -1,12 +1,18 @@ use shared::error::TuliproxError; -use shared::model::WebUiConfigDto; +use shared::model::{ContentSecurityPoliciesConfigDto, WebUiConfigDto}; use crate::model::{macros, WebAuthConfig}; +#[derive(Debug, Clone)] +pub struct ContentSecurityPoliciesConfig { + pub enabled: bool, + pub custom_attributes: Vec, +} + #[derive(Debug, Clone)] pub struct WebUiConfig { pub enabled: bool, pub user_ui_enabled: bool, - pub content_security_policy: bool, + pub content_security_policies: Option, pub path: Option, pub auth: Option, pub player_server: Option, @@ -25,13 +31,25 @@ impl WebUiConfig { } } +macros::from_impl!(ContentSecurityPoliciesConfig); +impl From<&ContentSecurityPoliciesConfigDto> for ContentSecurityPoliciesConfig { + fn from(dto: &ContentSecurityPoliciesConfigDto) -> Self { + Self { enabled: dto.enabled, custom_attributes: dto.custom_attributes.clone() } + } +} +impl From<&ContentSecurityPoliciesConfig> for ContentSecurityPoliciesConfigDto { + fn from(instance: &ContentSecurityPoliciesConfig) -> Self { + Self { enabled: instance.enabled, custom_attributes: instance.custom_attributes.clone() } + } +} + macros::from_impl!(WebUiConfig); impl From<&WebUiConfigDto> for WebUiConfig { fn from(dto: &WebUiConfigDto) -> Self { Self { enabled: dto.enabled, user_ui_enabled: dto.user_ui_enabled, - content_security_policy: dto.content_security_policy, + content_security_policies: dto.content_security_policies.as_ref().map(Into::into), path: dto.path.clone(), auth: dto.auth.as_ref().map(Into::into), player_server: dto.player_server.clone(), @@ -43,10 +61,11 @@ impl From<&WebUiConfig> for WebUiConfigDto { Self { enabled: instance.enabled, user_ui_enabled: instance.user_ui_enabled, - content_security_policy: instance.content_security_policy, + content_security_policies: instance.content_security_policies.as_ref().map(Into::into), path: instance.path.clone(), auth: instance.auth.as_ref().map(Into::into), player_server: instance.player_server.clone(), } } -} \ No newline at end of file +} + diff --git a/config/config.yml b/config/config.yml index d8a3c17ee..d4b5ef9a7 100644 --- a/config/config.yml +++ b/config/config.yml @@ -27,6 +27,19 @@ update_on_boot: false # best not to hammer upstream during testing web_ui: enabled: true user_ui_enabled: true + content-security-policies: + enabled: true + custom-attributes: + - "default-src 'self'" + - "script-src 'self' nonce-{nonce_b64}" + - "frame-ancestors 'none'" + - "style-src 'self'" + - "img-src 'self' data:" + - "font-src 'self' data:" + - "connect-src 'self' wss:" + - "object-src 'none'" + - "base-uri 'self'" + - "form-action 'self'" path: auth: enabled: true diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 135fca0a7..a66357404 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -244,7 +244,8 @@ "STRIP": "Strip", "COPY_LINK_TULIPROX": "Copy Virtual Id", "COPY_LINK_PROVIDER": "Copy Provider Url", - "CONTENT_SECURITY_POLICY" : "Content Security Policy" + "CONTENT_SECURITY_POLICY" : "Content Security Policy", + "CUSTOM_ATTRIBUTES": "Custom Attributes" }, "TABLE": { "EMPTY": "", diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index cb6b58943..a6918eeaf 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -1,8 +1,8 @@ -use crate::app::components::Card; +use crate::app::components::{Card, Chip}; use crate::app::context::ConfigContext; use crate::{ - config_field, config_field_bool, config_field_bool_empty, config_field_empty, - config_field_hide, config_field_optional, + config_field, config_field_bool, config_field_bool_empty, config_field_child, + config_field_empty, config_field_hide, config_field_optional, }; use yew::prelude::*; use yew_i18n::use_translation; @@ -30,7 +30,14 @@ pub fn WebUiConfigView() -> Html { <> { config_field_bool_empty!(translate.t("LABEL.ENABLED")) } { config_field_bool_empty!(translate.t("LABEL.USER_UI_ENABLED")) } - { config_field_bool_empty!(translate.t("LABEL.CONTENT_SECURITY_POLICY")) } + { config_field_child!(translate.t("LABEL.CONTENT_SECURITY_POLICY"), { + html! { + <> + { config_field_bool_empty!(translate.t("LABEL.ENABLED")) } + { config_field_empty!(translate.t("LABEL.CUSTOM_ATTRIBUTES")) } + + } + }) } { config_field_empty!(translate.t("LABEL.PATH")) } { config_field_empty!(translate.t("LABEL.PLAYER_SERVER")) } { render_empty_auth()} @@ -48,7 +55,26 @@ pub fn WebUiConfigView() -> Html { <> { config_field_bool!(web_ui, translate.t("LABEL.ENABLED"), enabled) } { config_field_bool!(web_ui, translate.t("LABEL.USER_UI_ENABLED"), user_ui_enabled) } - { config_field_bool!(web_ui, translate.t("LABEL.CONTENT_SECURITY_POLICY"), content_security_policy) } + { config_field_child!(translate.t("LABEL.CONTENT_SECURITY_POLICY"), { + html! { + match web_ui.content_security_policies.as_ref() { + Some(csp) => html! { + <> + { config_field_bool!(csp, translate.t("LABEL.ENABLED"), enabled) } + { config_field_child!(translate.t("LABEL.CUSTOM_ATTRIBUTES"), { + html! {
{ for csp.custom_attributes.iter().map(|a| html! { }) }
} + }) } + + }, + None => html! { + <> + { config_field_bool_empty!(translate.t("LABEL.ENABLED")) } + { config_field_empty!(translate.t("LABEL.CUSTOM_ATTRIBUTES")) } + + } + } + } + }) } { config_field_optional!(web_ui, translate.t("LABEL.PATH"), path) } { config_field_optional!(web_ui, translate.t("LABEL.PLAYER_SERVER"), player_server) } diff --git a/shared/src/model/config/web_ui.rs b/shared/src/model/config/web_ui.rs index cf79c63e3..351a61a2b 100644 --- a/shared/src/model/config/web_ui.rs +++ b/shared/src/model/config/web_ui.rs @@ -1,13 +1,34 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::WebAuthConfigDto; -use crate::utils::{default_as_true}; +use crate::utils::default_as_true; const RESERVED_PATHS: &[&str] = &[ - "live", "movie", "series", "m3u-stream", "healthcheck", "status", - "player_api.php", "panel_api.php", "xtream", "timeshift", "timeshift.php", "streaming", - "get.php", "apiget", "m3u", "resource" + "live", + "movie", + "series", + "m3u-stream", + "healthcheck", + "status", + "player_api.php", + "panel_api.php", + "xtream", + "timeshift", + "timeshift.php", + "streaming", + "get.php", + "apiget", + "m3u", + "resource", ]; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct ContentSecurityPoliciesConfigDto { + #[serde(default)] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_attributes: Vec, +} #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] @@ -16,8 +37,12 @@ pub struct WebUiConfigDto { pub enabled: bool, #[serde(default = "default_as_true")] pub user_ui_enabled: bool, - #[serde(default)] - pub content_security_policy: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "content-security-policies" + )] + pub content_security_policies: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -37,13 +62,20 @@ impl WebUiConfigDto { if web_path.is_empty() { self.path = None; } else { - let web_path = web_path.trim().trim_start_matches('/').trim_end_matches('/').to_string(); + let web_path = web_path + .trim() + .trim_start_matches('/') + .trim_end_matches('/') + .to_string(); if RESERVED_PATHS.contains(&web_path.to_lowercase().as_str()) { - return Err(TuliproxError::new(TuliproxErrorKind::Info, format!("web ui path is a reserved path. Do not use {RESERVED_PATHS:?}"))); + return Err(TuliproxError::new( + TuliproxErrorKind::Info, + format!("web ui path is a reserved path. Do not use {RESERVED_PATHS:?}"), + )); } self.path = Some(web_path); } } Ok(()) } -} \ No newline at end of file +}