feat: make content security policy configurable

This commit is contained in:
knylbyte
2025-08-18 13:46:23 +02:00
parent 0168953351
commit e2c6aff5dc
8 changed files with 142 additions and 27 deletions
+1 -1
View File
@@ -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
+14 -2
View File
@@ -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
+16 -4
View File
@@ -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));
}
+24 -5
View File
@@ -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<String>,
}
#[derive(Debug, Clone)]
pub struct WebUiConfig {
pub enabled: bool,
pub user_ui_enabled: bool,
pub content_security_policy: bool,
pub content_security_policies: Option<ContentSecurityPoliciesConfig>,
pub path: Option<String>,
pub auth: Option<WebAuthConfig>,
pub player_server: Option<String>,
@@ -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(),
}
}
}
}
+13
View File
@@ -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
+2 -1
View File
@@ -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": "",
@@ -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! { <div class="tp__config-view__tags">{ for csp.custom_attributes.iter().map(|a| html! { <Chip label={a.clone()} /> }) }</div> }
}) }
</>
},
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) }
<Card>
+41 -9
View File
@@ -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<String>,
}
#[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<ContentSecurityPoliciesConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[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(())
}
}
}