mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-04 06:52:26 +02:00
feat: make content security policy configurable
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user