From 2adc18adbc6e759cfcff5b78bdc1fd2c3de10065 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:02:22 +0200 Subject: [PATCH] resource policy trusted destinations (#875) * **New Features** * Added a Resource Policy editor for each input, supporting trusted hostnames and private network ranges. * Resource URLs now retain their source input and use authenticated links where supported. * Resource caching is scoped to the policy authorizing access. * **Bug Fixes** * Invalid or unsupported resource values are safely discarded. * Redirects and destination addresses are rechecked against the applicable policy. * **Breaking Changes** * Private DNS destinations require approved hosts and networks; private IP literals require an approved network. * Input and alias names must be non-empty and globally unique. --- CHANGELOG.md | 40 ++ Cargo.lock | 1 + Cargo.toml | 1 + backend/app/src/api/api_utils/mod.rs | 220 +++++--- backend/app/src/api/api_utils/resource.rs | 283 ++++++++++ backend/app/src/api/api_utils/tests.rs | 297 +++++++++-- .../src/api/endpoints/api_playlist_utils.rs | 94 +++- .../api/endpoints/custom_video_stream_api.rs | 12 +- .../src/api/endpoints/download_api/tests.rs | 8 +- .../app/src/api/endpoints/hls_api/tests.rs | 13 +- backend/app/src/api/endpoints/m3u_api.rs | 43 +- .../app/src/api/endpoints/v1_api_playlist.rs | 81 ++- backend/app/src/api/endpoints/xmltv_api.rs | 123 ++++- .../app/src/api/endpoints/xtream_api/mod.rs | 38 +- backend/app/src/api/main_api.rs | 12 +- backend/app/src/api/model/app_state.rs | 73 ++- .../api/model/streams/active_client_stream.rs | 12 +- backend/core/src/model/config/input.rs | 53 +- backend/core/src/model/config/mod.rs | 2 + .../core/src/model/config/resource_policy.rs | 486 ++++++++++++++++++ backend/core/src/model/config/source.rs | 150 +++++- backend/core/src/model/provider.rs | 1 + backend/core/src/model/xmltv.rs | 2 + backend/core/src/utils/lru_cache.rs | 36 ++ backend/core/src/utils/mod.rs | 2 + backend/core/src/utils/network/request.rs | 277 +++++++++- backend/core/src/utils/resource_token.rs | 135 +++++ backend/metadata/src/manager.rs | 41 +- backend/parser/src/m3u.rs | 15 +- backend/parser/src/xtream.rs | 3 + backend/processing/src/epg.rs | 12 +- backend/processing/src/parser/xmltv.rs | 30 +- backend/processing/src/processor/epg.rs | 14 +- backend/processing/src/processor/library.rs | 11 +- .../src/processor/playlist/tests.rs | 2 + .../src/processor/playlist/transform.rs | 16 + backend/processing/src/processor/stalker.rs | 2 +- .../src/processor/stalker_refresh.rs | 26 +- backend/repository/src/xtream_repository.rs | 9 +- .../session/src/provider_lineup_manager.rs | 1 + docs/src/configuration/source.md | 116 ++++- frontend/public/assets/i18n/ar.json | 7 + frontend/public/assets/i18n/en.json | 7 + frontend/public/assets/i18n/ru.json | 7 + .../components/source_editor/input_form.rs | 146 +++++- shared/Cargo.toml | 3 + shared/src/model/config/input.rs | 7 +- shared/src/model/config/mod.rs | 2 + shared/src/model/config/resource_policy.rs | 19 + shared/src/model/epg.rs | 72 +++ shared/src/model/mod.rs | 5 +- shared/src/model/playlist.rs | 279 +++++++++- shared/src/model/resource.rs | 350 +++++++++++++ shared/src/model/stalker_item.rs | 30 +- shared/src/model/stream_properties.rs | 157 +++++- 55 files changed, 3575 insertions(+), 309 deletions(-) create mode 100644 backend/app/src/api/api_utils/resource.rs create mode 100644 backend/core/src/model/config/resource_policy.rs create mode 100644 backend/core/src/utils/resource_token.rs create mode 100644 shared/src/model/config/resource_policy.rs create mode 100644 shared/src/model/resource.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80147a3bf..1020a128e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ ## ⚠️ Breaking Changes +- **Proxied resource URLs are now restricted to public destinations by default.** Tuliprox proxies external + resource URLs that come from provider, playlist, and EPG content: channel and small logos, EPG channel and + programme icons, cover images, posters, and backdrops. These requests now enforce a destination policy on every + route that serves them (`/resource/m3u/...`, the Xtream resource routes, `/resource/epg/...`, and + `/api/v1/playlist/resource/...`), where previously three of them fetched any destination reachable by the + configured HTTP client. + - A resource URL whose DNS host name resolves to a private address (RFC 1918 or IPv6 ULA) is rejected unless the + input that supplied it lists the exact host name in `resource_policy.allowed_hosts` **and** the address in + `resource_policy.allowed_networks`. A private IP literal requires only a matching + `resource_policy.allowed_networks` entry because IP literals are not valid `allowed_hosts` values. Add the policy + to the input that provides the logo or icon; for icons that `logo_override` copies out of EPG, that is the EPG + input. + - Loopback, link-local, cloud-metadata, CGNAT, multicast, and reserved addresses stay blocked with or without a + policy. Redirects are re-checked on every hop and are bounded. + - Resource ownership is stored generically with each URL, including nested cover, poster, backdrop, and episode + image fields. Legacy raw playlist/Xtream item resources use their containing item's input; legacy EPG resources + without an authoritative input remain public-only until regenerated. + - `resource://` is an internal reserved scheme. Provider data and mapping configuration must never supply it; + such values are rejected rather than interpreted as authorization claims. + - The canonical input name is the authorization identity of a resource origin. Configured input and alias names + must be non-empty, globally unique strings; a configuration with duplicate input names, duplicate alias names, + or an alias name that shadows an input name is now rejected while loading. Internal IDs are managed separately. + - The resource cache is keyed by the policy that authorized the entry, so an entry fetched under one policy is + never served to another. The cache starts cold once on upgrade because the key layout changes. + - Resource proxying now always connects directly: a configured proxy and the `HTTP_PROXY` / `HTTPS_PROXY` / + `ALL_PROXY` environment variables are ignored for these requests, and Tuliprox logs a warning at startup and on + reload when one is set. Provider fetches, playlist and EPG downloads, and streams keep using the proxy. + - The Source Editor exposes the policy on every input under the shield-shaped **Resource Policy** page. Empty host + and network lists restore the public-only default and omit the policy from the saved input. + - See [Resource Policy](docs/src/configuration/source.md#27-resource-policy-resource_policy) for the parameters + and the exact host-plus-network rule. + - **The Web UI WebSocket protocol is now version 4.** Playlist update completion messages carry the correlated run ID and execution order instead of a bare status. Reload existing browser tabs after upgrading the server; version-3 clients are rejected during the handshake rather than receiving incompatible update messages. @@ -1336,6 +1368,14 @@ ## ⚙️ New Settings +- **source.yml (input `resource_policy`)**: Added an optional per-input policy for private resource destinations. + - `allowed_hosts` (list of exact DNS names, default empty) and `allowed_networks` (list of private CIDR ranges, + default empty) authorize a private address only together: the host name must match and the resolved address must + fall inside one of the networks. An IP literal is authorized by `allowed_networks` alone. An absent or empty + policy means public-only. + - Invalid entries (scheme, path, port, wildcard, IP literal in `allowed_hosts`; a range outside + `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, or `fc00::/7`) are rejected while the configuration is loaded. + - **Runtime diagnostics (environment variables)**: - `TULIPROX_WATCHDOG` (default unset = off) is a mode selector: `1` (`true`/`on`/`yes`/`enabled`) observes and logs stalls, `2` (`restart`) additionally exits the process after the stall persists so a supervisor restarts it. diff --git a/Cargo.lock b/Cargo.lock index dc46bde6a..9431b18ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3805,6 +3805,7 @@ dependencies = [ "pest", "pest_derive", "regex", + "rmp-serde", "serde", "serde-saphyr", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dad47066d..2a44fce79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ incremental = false [profile.dev] lto = false incremental = true +debug = "line-tables-only" codegen-units = 256 [profile.dev-slim] diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index 949c96235..3e8e48a16 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -1,3 +1,4 @@ +mod resource; pub use crate::repository::{ evaluate_network_access, log_network_access_allowed_geoip_unavailable, log_network_access_denied, NetworkAccessDecision, NetworkAccessDenyReason, @@ -27,6 +28,7 @@ use crate::{ }, model::{ AppConfig, ConfigInput, ConfigInputFlags, ConfigTarget, InputUserInfo, PlaybackKind, ProxyUserCredentials, + ReverseProxyDisabledHeaderConfig, }, processing::{ parser::hls::{rewrite_hls, RewriteHlsProps}, @@ -36,7 +38,7 @@ use crate::{ utils::{ async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request, request::{content_type_from_ext, parse_range, send_with_retry_and_provider}, - trace_if_enabled, + resource_cache_key, trace_if_enabled, }, BUILD_TIMESTAMP, }; @@ -50,6 +52,11 @@ use bytes::{Bytes, BytesMut}; use chrono::{DateTime, Utc}; use futures::{stream, Stream, StreamExt, TryStreamExt}; use log::{debug, error, info, log_enabled, trace, warn}; +pub use resource::{ + decode_resource_link, log_missing_resource_client, log_resource_rejection, rejection_status, resolve_resource, + resolve_resource_authorization, ResolvedResource, ResolvedResourceAuthorization, ResourceCacheMode, + ResourceFetchOptions, +}; use serde::Serialize; use shared::{ concat_string, @@ -3590,6 +3597,60 @@ pub fn is_hls_stream_share_enabled(target: &ConfigTarget) -> bool { target.options.as_ref().is_some_and(ConfigTargetOptions::share_live_hls_enabled) } +fn resource_credential_context(input: Option<&ConfigInput>, request_headers: &HashMap>) -> String { + fn update_field(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_be_bytes()); + hasher.update(value); + } + + fn update_headers(hasher: &mut blake3::Hasher, headers: impl Iterator)>) { + let mut headers = headers.collect::>(); + headers.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + for (name, value) in headers { + update_field(hasher, name.as_bytes()); + update_field(hasher, &value); + } + } + + let mut hasher = blake3::Hasher::new(); + hasher.update(b"tuliprox.resource.credentials.v1"); + if let Some(input) = input { + update_field(&mut hasher, input.name.as_bytes()); + update_field(&mut hasher, input.username.as_deref().unwrap_or_default().as_bytes()); + update_field(&mut hasher, input.password.as_deref().unwrap_or_default().as_bytes()); + update_headers( + &mut hasher, + input + .headers + .iter() + .filter(|(name, _)| !request::is_safe_cross_origin_redirect_header(name)) + .map(|(name, value)| (name.to_ascii_lowercase(), value.as_bytes().to_vec())), + ); + } else { + update_field(&mut hasher, &[]); + update_field(&mut hasher, &[]); + update_field(&mut hasher, &[]); + } + update_headers( + &mut hasher, + request_headers + .iter() + .filter(|(name, _)| !request::is_safe_cross_origin_redirect_header(name)) + .map(|(name, value)| (name.to_ascii_lowercase(), value.clone())), + ); + hasher.finalize().to_hex().to_string() +} + +fn resource_request_has_authorization(input: Option<&ConfigInput>, request_headers: &HashMap>) -> bool { + input.is_some_and(|input| { + input.headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::AUTHORIZATION.as_str()) && HeaderValue::from_str(value).is_ok() + }) + }) || request_headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::AUTHORIZATION.as_str()) && HeaderValue::from_bytes(value).is_ok() + }) +} + fn get_add_cache_content( res_url: &str, mime_type: Option, @@ -3648,6 +3709,7 @@ async fn build_resource_stream_response( cache_key: Option<&str>, resource_url: &str, response: reqwest::Response, + credential_varying: bool, ) -> axum::response::Response { let sanitized_resource_url = sanitize_sensitive_info(resource_url); let status = response.status(); @@ -3660,7 +3722,11 @@ async fn build_resource_stream_response( } } - if !response_builder.headers_ref().is_some_and(|h| h.contains_key(header::CACHE_CONTROL)) { + if credential_varying { + if let Some(headers) = response_builder.headers_mut() { + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + } + } else if !response_builder.headers_ref().is_some_and(|h| h.contains_key(header::CACHE_CONTROL)) { response_builder = response_builder.header(header::CACHE_CONTROL, "public, max-age=14400"); } @@ -3702,21 +3768,26 @@ async fn build_resource_stream_response( try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(byte_stream))) } +struct ResourceRequestContext<'a> { + headers: &'a HashMap>, + input: Option<&'a ConfigInput>, + disabled_headers: Option<&'a ReverseProxyDisabledHeaderConfig>, + credential_varying: bool, +} + async fn fetch_resource_with_retry( app_state: &Arc, http_client: &reqwest::Client, url: &Url, cache_key: Option<&str>, resource_url: &str, - req_headers: &HashMap>, - input: Option<&ConfigInput>, + request_context: ResourceRequestContext<'_>, ) -> Option { + let ResourceRequestContext { headers, input, disabled_headers, credential_varying } = request_context; let config = app_state.app_config.config.load(); let default_user_agent = config.default_user_agent.clone(); drop(config); - let disabled_headers = app_state.get_disabled_headers(); - let provider_config = input.and_then(|i| i.get_resolve_provider(url.as_str())); let Ok(response) = send_with_retry_and_provider(&app_state.app_config, url, provider_config.as_ref(), false, |resolved_url| { @@ -3725,8 +3796,8 @@ async fn fetch_resource_with_retry( input.map_or(InputFetchMethod::GET, |i| i.method), input.map(|i| &i.headers), resolved_url, - Some(req_headers), - disabled_headers.as_ref(), + Some(headers), + disabled_headers, default_user_agent.as_deref(), ) }) @@ -3738,7 +3809,9 @@ async fn fetch_resource_with_retry( let status = response.status(); if status.is_success() { - return Some(build_resource_stream_response(app_state, cache_key, resource_url, response).await); + return Some( + build_resource_stream_response(app_state, cache_key, resource_url, response, credential_varying).await, + ); } // Non-retriable Status -> Upstream Response incl. Body @@ -3750,47 +3823,21 @@ async fn fetch_resource_with_retry( response_builder = response_builder.header(key, value); } } + if credential_varying { + if let Some(headers) = response_builder.headers_mut() { + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + } + } let stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream)))) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ResourceFetchPolicy { - Standard, - PublicNoRedirect, -} - -impl ResourceFetchPolicy { - const fn cache_key(self, resource_url: &str) -> Option<&str> { - match self { - Self::Standard => Some(resource_url), - Self::PublicNoRedirect => None, - } - } - - const fn requires_public_destination(self) -> bool { matches!(self, Self::PublicNoRedirect) } -} - -async fn validate_public_resource_destination(url: &Url) -> std::io::Result<()> { - if !matches!(url.scheme(), "http" | "https") { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "unsupported resource URL scheme")); - } - let host = url - .host_str() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "resource URL has no host"))?; - let port = url - .port_or_known_default() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "resource URL has no port"))?; - tuliprox_core::utils::network::request::resolve_public_socket_addrs(host, port).await?; - Ok(()) -} - /// # Panics pub async fn resource_response( app_state: &Arc, - fetch_policy: ResourceFetchPolicy, + options: ResourceFetchOptions, resource_url: &str, req_headers: &HeaderMap, input: Option<&ConfigInput>, @@ -3799,20 +3846,8 @@ pub async fn resource_response( return StatusCode::NO_CONTENT.into_response(); } - let validated_url = if fetch_policy.requires_public_destination() { - let Ok(url) = Url::parse(resource_url) else { - error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); - return StatusCode::BAD_REQUEST.into_response(); - }; - if let Err(err) = validate_public_resource_destination(&url).await { - debug!("Rejected non-public resource destination {}: {err}", sanitize_sensitive_info(resource_url)); - return StatusCode::BAD_GATEWAY.into_response(); - } - Some(url) - } else { - None - }; - + // Dispatched before any HTTP(S) policy decision: these URLs are served by a dedicated + // media-server path and never leave through the resource proxy. if resource_url.starts_with("media-server://image/") { return match open_media_server_image_resource(app_state, resource_url).await { Ok(response) => response, @@ -3827,10 +3862,49 @@ pub async fn resource_response( } }; } + + let authorization = &options.authorization; + let Ok(url) = Url::parse(resource_url) else { + error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); + return StatusCode::BAD_REQUEST.into_response(); + }; + + // Scheme, host presence, and IP literals are checked before the first request. Host names are + // left to the connection-time policy resolver, which is also the only place that sees the real + // address a name resolves to. + if let Err(err) = authorization.policy.validate_initial_url(&url) { + log_resource_rejection(authorization.input_name.as_deref(), &err, resource_url); + return rejection_status(&err).into_response(); + } + + let Some(http_client) = + app_state.resource_clients.load().client(&authorization.policy_digest, options.redirect_mode).cloned() + else { + log_missing_resource_client(authorization.input_name.as_deref(), &authorization.policy_digest); + return StatusCode::BAD_GATEWAY.into_response(); + }; + let filter: HeaderFilter = Some(Box::new(|key| key != "if-none-match" && key != "if-modified-since")); let req_headers = get_headers_from_request(req_headers, &filter); - let cache_key = fetch_policy.cache_key(resource_url); - if let (Some(cache_key), Some(cache)) = (cache_key, app_state.cache.load().as_ref()) { + let configured_input = + authorization.input_name.as_ref().and_then(|input_name| app_state.app_config.get_input_by_name(input_name)); + let effective_input = configured_input.as_deref().or(input); + let credential_context = resource_credential_context(effective_input, &req_headers); + let disabled_headers = app_state.get_disabled_headers(); + let authorization_disabled = + disabled_headers.as_ref().is_some_and(|disabled| disabled.should_remove(header::AUTHORIZATION.as_str())); + let credential_varying = + !authorization_disabled && resource_request_has_authorization(effective_input, &req_headers); + let cache_key = match options.cache_mode { + ResourceCacheMode::Enabled if !credential_varying => Some(resource_cache_key( + authorization.policy_digest.as_str(), + authorization.input_name.as_deref().unwrap_or_default(), + &credential_context, + url.as_str(), + )), + ResourceCacheMode::Enabled | ResourceCacheMode::Disabled => None, + }; + if let (Some(cache_key), Some(cache)) = (cache_key.as_deref(), app_state.cache.load().as_ref()) { let cache_hit = { let mut guard = cache.write().await; guard.get_content(cache_key) @@ -3848,21 +3922,25 @@ pub async fn resource_response( } } trace_if_enabled!("Try to fetch resource {}", sanitize_sensitive_info(resource_url)); - if let Ok(url) = validated_url.map_or_else(|| Url::parse(resource_url), Ok) { - let http_client = match fetch_policy { - ResourceFetchPolicy::Standard => app_state.http_client.load(), - ResourceFetchPolicy::PublicNoRedirect => app_state.public_http_client_no_redirect.load(), - }; - if let Some(resp) = - fetch_resource_with_retry(app_state, &http_client, &url, cache_key, resource_url, &req_headers, input).await - { - return resp; - } - // Upstream failure after retries - return StatusCode::BAD_GATEWAY.into_response(); + if let Some(resp) = fetch_resource_with_retry( + app_state, + &http_client, + &url, + cache_key.as_deref(), + resource_url, + ResourceRequestContext { + headers: &req_headers, + input: effective_input, + disabled_headers: disabled_headers.as_ref(), + credential_varying, + }, + ) + .await + { + return resp; } - error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); - StatusCode::BAD_REQUEST.into_response() + // Upstream failure after retries + StatusCode::BAD_GATEWAY.into_response() } async fn open_media_server_image_resource( diff --git a/backend/app/src/api/api_utils/resource.rs b/backend/app/src/api/api_utils/resource.rs new file mode 100644 index 000000000..6a2db26f9 --- /dev/null +++ b/backend/app/src/api/api_utils/resource.rs @@ -0,0 +1,283 @@ +//! Authorization of proxied HTTP(S) resource requests. +//! +//! Every resource request decides which input supplied the concrete URL, looks up that input's +//! policy, and then runs the fetch through the client that belongs to the policy. There is no +//! permissive default: an origin that cannot be resolved is a rejection, and a missing client is a +//! rejection as well. + +use crate::model::AppConfig; +use axum::http::StatusCode; +use log::{debug, error, warn}; +use shared::{model::resolve_resource_value, utils::sanitize_sensitive_info}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock, Mutex}, + time::{Duration, Instant}, +}; +use tuliprox_core::{ + model::{public_only_policy, PolicyDigest, ResourcePolicy, ResourcePolicyError, ResourceRedirectMode}, + utils::{decode_resource_token, has_resource_token_prefix}, +}; + +/// Whether a resource fetch may be served from (and stored in) the resource cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceCacheMode { + Disabled, + Enabled, +} + +/// The authorization a resource request ended up with. +#[derive(Clone, Debug)] +pub struct ResolvedResourceAuthorization { + /// Canonical main input name. `None` only for data without any origin. + pub input_name: Option>, + /// Normalized policy. An empty policy is public-only. + pub policy: Arc, + pub policy_digest: PolicyDigest, +} + +#[derive(Clone, Debug)] +pub struct ResolvedResource { + pub url: Arc, + pub authorization: ResolvedResourceAuthorization, +} + +impl ResolvedResourceAuthorization { + /// Authorization for data without an origin: public destinations only. + pub fn public_only() -> Self { + let policy = public_only_policy(); + Self { input_name: None, policy_digest: policy.digest(), policy } + } + + /// Authorization for an input that is known to be configured. + pub fn for_input(input_name: Arc, policy: Option<&Arc>) -> Self { + let policy = policy.cloned().unwrap_or_else(public_only_policy); + Self { input_name: Some(input_name), policy_digest: policy.digest(), policy } + } +} + +/// Everything the shared resource path needs besides the URL itself. +#[derive(Clone)] +pub struct ResourceFetchOptions { + pub authorization: ResolvedResourceAuthorization, + pub redirect_mode: ResourceRedirectMode, + pub cache_mode: ResourceCacheMode, +} + +impl ResourceFetchOptions { + /// EPG delivery: an upstream redirect is returned to the client and nothing is cached. + pub fn epg(authorization: ResolvedResourceAuthorization) -> Self { + Self { authorization, redirect_mode: ResourceRedirectMode::NoRedirect, cache_mode: ResourceCacheMode::Disabled } + } + + /// Cached resource routes: bounded redirects and cache participation. + pub fn cached(authorization: ResolvedResourceAuthorization) -> Self { + Self { authorization, redirect_mode: ResourceRedirectMode::Bounded, cache_mode: ResourceCacheMode::Enabled } + } +} + +/// Resolves the originating input of a resource value to its current policy. +/// +/// Three outcomes are kept apart on purpose: +/// - no origin at all (legacy records, legacy links) becomes public-only; +/// - an origin naming an unknown or disabled input is rejected; +/// - a valid origin with an empty policy becomes public-only. +pub fn resolve_resource_authorization( + app_config: &AppConfig, + input_name: Option<&Arc>, +) -> Result { + let Some(input_name) = input_name else { + return Ok(ResolvedResourceAuthorization::public_only()); + }; + + let sources = app_config.sources.load(); + // The lookup only contains enabled inputs and their enabled aliases, so an unknown name and a + // disabled input are both rejected here. + let Some(main_name) = sources.group_lookup.get(input_name) else { + return Err(ResourcePolicyError::UnknownOrigin(input_name.to_string())); + }; + let Some(input) = sources.get_input_by_name(main_name) else { + return Err(ResourcePolicyError::UnknownOrigin(input_name.to_string())); + }; + + Ok(ResolvedResourceAuthorization::for_input(Arc::clone(main_name), input.resource_policy.as_ref())) +} + +/// Resolves provenance before the URL reaches any transport client. A valid locator is +/// authoritative and can never fall back to the containing record's input. +pub fn resolve_resource( + app_config: &AppConfig, + value: &str, + legacy_input_name: Option<&Arc>, +) -> Result { + let locator = resolve_resource_value(value).map_err(|_| ResourcePolicyError::UnsupportedScheme)?; + let (url, input_name) = locator.map_or_else( + || (Arc::from(value), legacy_input_name.cloned()), + |locator| (locator.url, Some(locator.input_name)), + ); + let authorization = resolve_resource_authorization(app_config, input_name.as_ref())?; + Ok(ResolvedResource { url, authorization }) +} + +/// Decodes a resource link with the decoder that belongs to the route. +/// +/// New links are authenticated tokens and carry their origin; legacy links were issued before +/// origin tracking and stay public-only. The two encodings are never tried against each other, so +/// a malformed token cannot downgrade to a weaker interpretation. +pub fn decode_resource_link(secret: &[u8; 16], encoded: &str, legacy_decode: F) -> Result +where + F: FnOnce(&[u8; 16], &str) -> Result, +{ + if has_resource_token_prefix(encoded) { + return decode_resource_token(secret, encoded).map(|token| token.resource).map_err(|_| StatusCode::BAD_REQUEST); + } + legacy_decode(secret, encoded).map_err(|()| StatusCode::BAD_REQUEST) +} + +/// Maps a policy rejection to the status the client sees. +/// +/// A rejected upstream is answered like the reverse proxy would answer a broken upstream, which +/// also keeps the internal allowlist out of the response. An unresolvable origin stays a client +/// error because the link or record itself is no longer usable. +pub const fn rejection_status(error: &ResourcePolicyError) -> StatusCode { + match error { + ResourcePolicyError::UnknownOrigin(_) => StatusCode::BAD_REQUEST, + ResourcePolicyError::BlockedAddress + | ResourcePolicyError::HostNotTrusted + | ResourcePolicyError::NetworkNotTrusted + | ResourcePolicyError::TooManyRedirects + | ResourcePolicyError::UnsupportedScheme + | ResourcePolicyError::MissingHost => StatusCode::BAD_GATEWAY, + } +} + +/// At most one warning per input and minute: a hostile playlist can generate unlimited rejections, +/// and the operator only needs the first occurrences to notice. +const REJECTION_LOG_WINDOW: Duration = Duration::from_mins(1); +const REJECTION_LOG_MAX_ENTRIES: usize = 512; + +static REJECTION_LOG: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Logs a rejected resource request with the origin input name. +/// +/// The destination URL is never part of the warning: it is untrusted input and the operator needs +/// the origin to fix the configuration, not the failing URL. +pub fn log_resource_rejection(input_name: Option<&str>, error: &ResourcePolicyError, resource_url: &str) { + debug!( + "Rejected resource request from input '{}': {error} ({})", + input_name.unwrap_or(""), + sanitize_sensitive_info(resource_url) + ); + + let key = input_name.unwrap_or("").to_string(); + let now = Instant::now(); + { + let Ok(mut log_state) = REJECTION_LOG.lock() else { + warn!("Rejected resource request from input '{key}': {error}"); + return; + }; + let visible = match log_state.get(&key) { + Some(last) => now.duration_since(*last) >= REJECTION_LOG_WINDOW, + None => true, + }; + if !visible { + return; + } + if log_state.len() >= REJECTION_LOG_MAX_ENTRIES { + log_state.retain(|_, last| now.duration_since(*last) < REJECTION_LOG_WINDOW); + } + log_state.insert(key.clone(), now); + } + + warn!("Rejected resource request from input '{key}': {error}"); +} + +/// Reports a request whose policy is not served by any current client. +/// +/// This happens when the configuration changed between resolving the origin and using it. The +/// request is rejected instead of falling back to a broader client. +pub fn log_missing_resource_client(input_name: Option<&str>, digest: &PolicyDigest) { + error!( + "No resource client for policy {} of input '{}'; rejecting instead of falling back", + digest.as_str(), + input_name.unwrap_or("") + ); +} + +#[cfg(test)] +mod tests { + use super::{rejection_status, ResourceCacheMode, ResourceFetchOptions}; + use axum::http::StatusCode; + use tuliprox_core::model::{public_only_policy, ResourcePolicyError}; + + #[test] + fn epg_and_cached_options_differ_in_redirect_and_cache_mode() { + let authorization = super::ResolvedResourceAuthorization::public_only(); + let epg = ResourceFetchOptions::epg(authorization.clone()); + let cached = ResourceFetchOptions::cached(authorization); + + assert_eq!(epg.cache_mode, ResourceCacheMode::Disabled); + assert_eq!(cached.cache_mode, ResourceCacheMode::Enabled); + assert_ne!(epg.redirect_mode, cached.redirect_mode); + } + + #[test] + fn rejection_status_separates_origin_errors_from_destination_errors() { + assert_eq!(rejection_status(&ResourcePolicyError::UnknownOrigin("gone".to_string())), StatusCode::BAD_REQUEST); + assert_eq!(rejection_status(&ResourcePolicyError::HostNotTrusted), StatusCode::BAD_GATEWAY); + assert_eq!(rejection_status(&ResourcePolicyError::BlockedAddress), StatusCode::BAD_GATEWAY); + } + + #[test] + fn originless_authorization_is_public_only() { + let authorization = super::ResolvedResourceAuthorization::public_only(); + assert_eq!(authorization.input_name, None); + assert_eq!(authorization.policy_digest, public_only_policy().digest()); + assert!(authorization.policy.is_empty()); + } + + #[test] + fn decode_resource_link_prefers_the_authenticated_token() { + let secret = [5u8; 16]; + let encoded = tuliprox_core::utils::encode_resource_token( + &secret, + &tuliprox_core::utils::resource_token("resource://v1/example"), + ) + .expect("encode"); + + let decoded = super::decode_resource_link(&secret, &encoded, |_, _| Err(())).expect("decode"); + + assert_eq!(decoded, "resource://v1/example"); + } + + #[test] + fn decode_resource_link_never_falls_back_to_the_legacy_decoder() { + let secret = [5u8; 16]; + // A malformed token must not be handed to the route's legacy decoder. + let malformed = format!("{}not-a-token", tuliprox_core::utils::RESOURCE_TOKEN_PREFIX); + + assert_eq!( + super::decode_resource_link(&secret, &malformed, |_, _| panic!("legacy decoder")), + Err(StatusCode::BAD_REQUEST) + ); + } + + #[test] + fn decode_resource_link_uses_the_route_decoder_for_legacy_values() { + let secret = [5u8; 16]; + + let legacy = + super::decode_resource_link(&secret, "legacy", |_, _| Ok("https://cdn.example.com/a.png".to_string())) + .expect("legacy decode"); + + assert_eq!(legacy, "https://cdn.example.com/a.png"); + assert_eq!(super::decode_resource_link(&secret, "broken", |_, _| Err(())).err(), Some(StatusCode::BAD_REQUEST)); + } + + #[test] + fn input_authorization_without_policy_stays_public_only_but_keeps_the_name() { + let authorization = super::ResolvedResourceAuthorization::for_input("local".into(), None); + assert_eq!(authorization.input_name.as_deref(), Some("local")); + assert_eq!(authorization.policy_digest, public_only_policy().digest()); + } +} diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index 9c4cd7035..a20288284 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -1,9 +1,9 @@ use super::*; use crate::{ api::model::{ - ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, EventManager, - MetadataUpdateManager, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, ProviderConfigConnection, - SharedStreamManager, + empty_resource_client_set, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, + EventManager, MetadataUpdateManager, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, + ProviderConfigConnection, ResourceClientSet, SharedStreamManager, }, auth::Fingerprint, model::{ @@ -27,9 +27,10 @@ use shared::{ defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs}, foundation::Filter, model::{ - AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigProviderDto, ConfigTargetOptions, GeoIpUnavailablePolicy, - InputFetchMethod, InputType, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProcessingOrder, - ProviderUrlSelectionPolicy, ProxyType, StreamChannel, TargetType, XtreamCluster, + provider_saturation::build_group_lookup, AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigProviderDto, + ConfigTargetOptions, GeoIpUnavailablePolicy, InputFetchMethod, InputType, PlaylistItem, PlaylistItemHeader, + PlaylistItemType, ProcessingOrder, ProviderUrlSelectionPolicy, ProxyType, ResourcePolicyDto, StreamChannel, + TargetType, XtreamCluster, }, utils::Internable, }; @@ -39,7 +40,10 @@ use tokio::{ net::TcpListener, sync::{mpsc, RwLock}, }; -use tuliprox_core::utils::response_compression::should_compress_response; +use tuliprox_core::{ + model::{public_only_policy, ResourceClientKey, ResourcePolicy, ResourcePolicyError, ResourceRedirectMode}, + utils::{resource_cache_key, response_compression::should_compress_response}, +}; use tuliprox_session::{ admission::{evaluate_remaining_strategies_after_grace, get_effective_admission_strategies}, AdmissionRejectionReason, GraceResolutionContext, @@ -4454,6 +4458,83 @@ fn create_test_app_config() -> AppConfig { } } +/// App config whose only input carries `policy` and one enabled alias. +fn create_policy_app_config(policy: ResourcePolicyDto) -> AppConfig { + let config = create_test_app_config(); + let policy = Arc::new(ResourcePolicy::from_dto(&policy).expect("test policy")); + let input = Arc::new(ConfigInput { + id: 1, + name: "main-input".intern(), + input_type: InputType::M3u, + url: "https://provider.example/playlist.m3u".to_string(), + enabled: true, + resource_policy: Some(policy), + aliases: Some(vec![ConfigInputAlias { + id: 2, + name: "aliased-input".intern(), + url: "https://provider.example/alias.m3u".to_string(), + username: None, + password: None, + priority: 0, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }); + let inputs = vec![Arc::clone(&input)]; + let sources = SourcesConfig { inputs, group_lookup: build_group_lookup(&[input]), ..SourcesConfig::default() }; + config.sources.store(Arc::new(sources)); + config +} + +#[test] +fn resource_authorization_uses_the_main_input_policy_for_an_alias() { + let app_config = create_policy_app_config(ResourcePolicyDto { + allowed_hosts: vec!["media.home.arpa".to_string()], + allowed_networks: vec!["192.168.50.20/32".to_string()], + }); + let input_name = Arc::from("aliased-input"); + let authorization = resolve_resource_authorization(&app_config, Some(&input_name)).expect("authorized"); + + assert_eq!(authorization.input_name.as_deref(), Some("main-input")); + assert!(authorization.policy.allows_host("media.home.arpa")); + assert_ne!(authorization.policy_digest, public_only_policy().digest()); +} + +#[test] +fn resource_authorization_rejects_an_unknown_or_disabled_origin() { + let app_config = create_policy_app_config(ResourcePolicyDto::default()); + let input_name = Arc::from("removed-input"); + let error = resolve_resource_authorization(&app_config, Some(&input_name)).expect_err("rejected"); + + assert_eq!(error, ResourcePolicyError::UnknownOrigin("removed-input".to_string())); + assert_eq!(rejection_status(&error), StatusCode::BAD_REQUEST); +} + +#[test] +fn resource_authorization_follows_a_config_change_without_regenerating_links() { + let app_config = create_policy_app_config(ResourcePolicyDto { + allowed_hosts: vec!["first.home.arpa".to_string()], + allowed_networks: vec!["192.168.50.20/32".to_string()], + }); + let input_name = Arc::from("main-input"); + let before = resolve_resource_authorization(&app_config, Some(&input_name)).expect("authorized"); + + let reloaded = create_policy_app_config(ResourcePolicyDto { + allowed_hosts: vec!["second.home.arpa".to_string()], + allowed_networks: vec!["10.0.0.0/8".to_string()], + }); + app_config.sources.store(Arc::clone(&reloaded.sources.load())); + + let after = resolve_resource_authorization(&app_config, Some(&input_name)).expect("authorized"); + + assert_ne!(before.policy_digest, after.policy_digest); + assert!(after.policy.allows_host("second.home.arpa")); + assert!(!after.policy.allows_host("first.home.arpa")); +} + fn create_test_provider_app_config() -> AppConfig { let input = Arc::new(ConfigInput { id: 1, @@ -4603,6 +4684,7 @@ fn create_test_app_state_for_config(app_cfg: Arc) -> Arc { http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(crate::api::model::DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, @@ -4633,8 +4715,30 @@ fn create_test_fingerprint(addr: std::net::SocketAddr) -> Fingerprint { Fingerprint::new(format!("fp-{addr}"), addr.ip().to_string(), addr) } +#[test] +fn resource_credential_context_separates_configured_and_inbound_credentials() { + let mut input = ConfigInput { + name: "input".into(), + username: Some("user".to_string()), + password: Some("password-a".to_string()), + ..ConfigInput::default() + }; + let no_headers = HashMap::new(); + let configured_a = resource_credential_context(Some(&input), &no_headers); + input.password = Some("password-b".to_string()); + let configured_b = resource_credential_context(Some(&input), &no_headers); + assert_ne!(configured_a, configured_b); + + let mut request_headers = HashMap::new(); + request_headers.insert("authorization".to_string(), b"Bearer a".to_vec()); + let inbound_a = resource_credential_context(None, &request_headers); + request_headers.insert("authorization".to_string(), b"Bearer b".to_vec()); + let inbound_b = resource_credential_context(None, &request_headers); + assert_ne!(inbound_a, inbound_b); +} + #[tokio::test] -async fn resource_cache_is_used_only_by_matching_standard_fetch_policy() { +async fn resource_cache_is_scoped_to_the_authorizing_policy() { const CACHED_BODY: &[u8] = b"cached image"; const UPSTREAM_BODY: &[u8] = b"upstream image"; @@ -4643,9 +4747,18 @@ async fn resource_cache_is_used_only_by_matching_standard_fetch_policy() { let cache_dir = temp_dir.path().to_string_lossy(); let mut cache = LRUResourceCache::new(1024, cache_dir.as_ref()); let resource_url = "http://1.1.1.1/icon.png"; - let cached_path = cache.store_path(resource_url, Some("image/png")); + + // An entry stored under the public-only scope: it must never answer a request authorized by a + // policy that is allowed to reach private destinations. + let public_only_scope = resource_cache_key( + public_only_policy().digest().as_str(), + "", + &resource_credential_context(None, &HashMap::new()), + resource_url, + ); + let cached_path = cache.store_path(&public_only_scope, Some("image/png")); tokio::fs::write(&cached_path, CACHED_BODY).await.expect("write cached image"); - cache.add_content(resource_url, Some("image/png".to_string()), CACHED_BODY.len()).expect("register cached image"); + cache.add_content(&public_only_scope, Some("image/png".to_string()), CACHED_BODY.len()).expect("cache entry"); app_state.cache.store(Some(Arc::new(RwLock::new(cache)))); let response_head = format!( @@ -4655,37 +4768,161 @@ async fn resource_cache_is_used_only_by_matching_standard_fetch_policy() { let (upstream_addr, upstream_task) = spawn_legacy_hls_test_origin(response_head, UPSTREAM_BODY.to_vec()).await; let proxy = reqwest::Proxy::http(format!("http://{upstream_addr}")).expect("mock proxy URL"); let mock_client = reqwest::Client::builder().proxy(proxy).build().expect("mock upstream client"); - app_state.public_http_client_no_redirect.store(Arc::new(mock_client)); - let standard_response = - resource_response(&app_state, ResourceFetchPolicy::Standard, resource_url, &HeaderMap::new(), None) + let public_only = ResolvedResourceAuthorization::public_only(); + let private_policy = Arc::new( + ResourcePolicy::from_dto(&shared::model::ResourcePolicyDto { + allowed_hosts: vec!["media.home.arpa".to_string()], + allowed_networks: vec!["192.168.50.20/32".to_string()], + }) + .expect("policy"), + ); + let scoped = ResolvedResourceAuthorization::for_input("private".into(), Some(&private_policy)); + assert_ne!(public_only.policy_digest, scoped.policy_digest); + + let mut clients = HashMap::new(); + for key in [ + ResourceClientKey::new(public_only.policy_digest.clone(), ResourceRedirectMode::Bounded), + ResourceClientKey::new(scoped.policy_digest.clone(), ResourceRedirectMode::Bounded), + ] { + clients.insert(key, mock_client.clone()); + } + app_state.resource_clients.store(Arc::new(ResourceClientSet::from_clients(clients))); + + let cached_response = resource_response( + &app_state, + ResourceFetchOptions::cached(public_only.clone()), + resource_url, + &HeaderMap::new(), + None, + ) + .await + .into_response(); + assert_eq!(cached_response.status(), StatusCode::OK); + let cached_body = cached_response.into_body().collect().await.expect("read cached image").to_bytes(); + assert_eq!(cached_body, Bytes::from_static(CACHED_BODY)); + + let fresh_response = + resource_response(&app_state, ResourceFetchOptions::cached(scoped), resource_url, &HeaderMap::new(), None) .await .into_response(); - assert_eq!(standard_response.status(), StatusCode::OK); - let standard_body = standard_response.into_body().collect().await.expect("read cached image").to_bytes(); - assert_eq!(standard_body, Bytes::from_static(CACHED_BODY)); - - let response = - resource_response(&app_state, ResourceFetchPolicy::PublicNoRedirect, resource_url, &HeaderMap::new(), None) - .await - .into_response(); - - assert_eq!(response.status(), StatusCode::OK); - let response_body = response.into_body().collect().await.expect("read upstream image").to_bytes(); - assert_eq!(response_body, Bytes::from_static(UPSTREAM_BODY)); - assert_ne!(response_body, Bytes::from_static(CACHED_BODY)); + assert_eq!(fresh_response.status(), StatusCode::OK); + let fresh_body = fresh_response.into_body().collect().await.expect("read upstream image").to_bytes(); + assert_eq!(fresh_body, Bytes::from_static(UPSTREAM_BODY)); + assert_ne!(fresh_body, Bytes::from_static(CACHED_BODY)); let upstream_request = upstream_task.await.expect("mock upstream task completes"); assert!(upstream_request.starts_with("GET http://1.1.1.1/icon.png HTTP/1.1\r\n")); } #[tokio::test] -async fn public_resource_destination_validation_rejects_loopback_url() { - let url = Url::parse("http://127.0.0.1/icon.png").expect("loopback URL"); +async fn resource_response_bypasses_shared_cache_for_forwarded_authorization() { + const CACHED_BODY: &[u8] = b"cached private image"; + const UPSTREAM_BODY: &[u8] = b"upstream private image"; - let result = validate_public_resource_destination(&url).await; + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = temp_dir.path().to_string_lossy(); + let mut cache = LRUResourceCache::new(1024, cache_dir.as_ref()); + let resource_url = "http://1.1.1.1/private-icon.png"; + let authorization = ResolvedResourceAuthorization::public_only(); + let credential_headers = HashMap::from([("authorization".to_string(), b"Bearer private".to_vec())]); + let cache_key = resource_cache_key( + authorization.policy_digest.as_str(), + "", + &resource_credential_context(None, &credential_headers), + resource_url, + ); + let cached_path = cache.store_path(&cache_key, Some("image/png")); + tokio::fs::write(&cached_path, CACHED_BODY).await.expect("write cached image"); + cache.add_content(&cache_key, Some("image/png".to_string()), CACHED_BODY.len()).expect("cache entry"); + app_state.cache.store(Some(Arc::new(RwLock::new(cache)))); - assert!(result.is_err_and(|err| err.kind() == std::io::ErrorKind::PermissionDenied)); + let response_head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nCache-Control: public, max-age=60\r\nConnection: close\r\n\r\n", + UPSTREAM_BODY.len() + ); + let (upstream_addr, upstream_task) = spawn_legacy_hls_test_origin(response_head, UPSTREAM_BODY.to_vec()).await; + let proxy = reqwest::Proxy::http(format!("http://{upstream_addr}")).expect("mock proxy URL"); + let mock_client = reqwest::Client::builder().proxy(proxy).build().expect("mock upstream client"); + let mut clients = HashMap::new(); + clients.insert( + ResourceClientKey::new(authorization.policy_digest.clone(), ResourceRedirectMode::Bounded), + mock_client, + ); + app_state.resource_clients.store(Arc::new(ResourceClientSet::from_clients(clients))); + + let mut request_headers = HeaderMap::new(); + request_headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer private")); + let response = resource_response( + &app_state, + ResourceFetchOptions::cached(authorization), + resource_url, + &request_headers, + None, + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).and_then(|value| value.to_str().ok()), + Some("private, no-store") + ); + let body = response.into_body().collect().await.expect("read upstream image").to_bytes(); + assert_eq!(body, Bytes::from_static(UPSTREAM_BODY)); + assert_ne!(body, Bytes::from_static(CACHED_BODY)); + + let upstream_request = tokio::time::timeout(std::time::Duration::from_secs(1), upstream_task) + .await + .expect("credential-varying request was not sent") + .expect("mock upstream task completes"); + assert!(upstream_request.to_ascii_lowercase().contains("authorization: bearer private\r\n")); +} + +#[tokio::test] +async fn resource_response_rejects_blocked_ip_literal_before_request() { + let app_state = create_test_app_state(); + let response_head = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(); + let (origin_addr, mut origin_task) = spawn_legacy_hls_test_origin(response_head, Vec::new()).await; + let authorization = ResolvedResourceAuthorization::public_only(); + let mut clients = HashMap::new(); + clients.insert( + ResourceClientKey::new(authorization.policy_digest.clone(), ResourceRedirectMode::Bounded), + reqwest::Client::builder().no_proxy().build().expect("test resource client"), + ); + app_state.resource_clients.store(Arc::new(ResourceClientSet::from_clients(clients))); + + let response = resource_response( + &app_state, + ResourceFetchOptions::cached(authorization), + &format!("http://{origin_addr}/icon.png"), + &HeaderMap::new(), + None, + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let origin_result = tokio::time::timeout(std::time::Duration::from_millis(250), &mut origin_task).await; + origin_task.abort(); + assert!(origin_result.is_err(), "blocked loopback request reached the test origin"); +} + +#[tokio::test] +async fn resource_response_rejects_a_policy_without_a_current_client() { + let app_state = create_test_app_state(); + let response = resource_response( + &app_state, + ResourceFetchOptions::cached(ResolvedResourceAuthorization::public_only()), + "http://1.1.1.1/icon.png", + &HeaderMap::new(), + None, + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } fn create_test_fingerprint_with_user_agent(addr: std::net::SocketAddr, user_agent: &str) -> Fingerprint { diff --git a/backend/app/src/api/endpoints/api_playlist_utils.rs b/backend/app/src/api/endpoints/api_playlist_utils.rs index 55f4c77f0..4a588ac67 100644 --- a/backend/app/src/api/endpoints/api_playlist_utils.rs +++ b/backend/app/src/api/endpoints/api_playlist_utils.rs @@ -4,6 +4,7 @@ use crate::{ empty_json_list_response, json_or_bin_response, stream_json_or_bin_response_stream, stream_json_or_bin_response_try_stream, }, + endpoints::xmltv_api::encode_resource_link, model::AppState, }, iptv::{m3u, xtream}, @@ -17,7 +18,10 @@ use crate::{ use axum::response::IntoResponse; use serde_json::json; use shared::{ - model::{InputPersistence, M3uPlaylistItem, TargetType, UiPlaylistItem, XtreamCluster, XtreamPlaylistItem}, + model::{ + resolve_resource_value, InputPersistence, M3uPlaylistItem, ResourceLocator, TargetType, UiPlaylistItem, + XtreamCluster, XtreamPlaylistItem, + }, utils::{concat_path, concat_path_leading_slash, interner_gc, obfuscate_text, Internable}, }; use std::sync::Arc; @@ -97,15 +101,38 @@ pub(in crate::api::endpoints) async fn get_playlist_for_target( (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response() } -fn rewrite_resource_url(encrypt_secret: &[u8; 16], resource_url: &str, item: UiPlaylistItem) -> UiPlaylistItem { - if item.logo.is_empty() { +pub(in crate::api::endpoints) fn rewrite_resource_url( + encrypt_secret: &[u8; 16], + resource_url: &str, + mut item: UiPlaylistItem, +) -> UiPlaylistItem { + if item.logo.is_empty() || item.logo.starts_with('/') { return item; } - let mut item = item; - if item.logo.starts_with('/') { - return item; - } - item.logo = concat_path(resource_url, &obfuscate_text(encrypt_secret, &item.logo)).intern(); + let resource = match resolve_resource_value(&item.logo) { + Ok(Some(_)) => Arc::clone(&item.logo), + Ok(None) => { + let Ok(resource) = ResourceLocator::new(Arc::clone(&item.input_name), Arc::clone(&item.logo)) + .and_then(|locator| locator.encode()) + else { + item.logo = Arc::from(""); + return item; + }; + resource + } + Err(_) => { + item.logo = Arc::from(""); + return item; + } + }; + let encoded = encode_resource_link(encrypt_secret, &resource).unwrap_or_else(|| { + let external = resolve_resource_value(&resource) + .ok() + .flatten() + .map_or_else(String::new, |locator| locator.url.to_string()); + obfuscate_text(encrypt_secret, &external) + }); + item.logo = concat_path(resource_url, &encoded).intern(); item } @@ -128,18 +155,26 @@ pub(in crate::api::endpoints) async fn get_playlist_for_input( accept: Option<&str>, ) -> impl IntoResponse + Send { if let Some(input) = cfg_input { + let config = app_state.app_config.config.load(); + let web_ui_path = config.web_ui.as_ref().and_then(|web_ui| web_ui.path.as_ref()).map_or("", String::as_str); + let resource_url = concat_path_leading_slash(web_ui_path, "api/v1/playlist/resource"); + let encrypt_secret = app_state.get_encrypt_secret(); if input.input_type.is_xtream() { let Some(channel_iterator) = iter_raw_xtream_input_playlist(&app_state.app_config, input, cluster).await else { return empty_json_list_response(); }; - let converted_stream = channel_iterator.map(|entry| entry.map(UiPlaylistItem::from)); + let converted_stream = channel_iterator.map(move |entry| { + entry.map(|item| rewrite_resource_url(&encrypt_secret, &resource_url, UiPlaylistItem::from(item))) + }); return stream_json_or_bin_response_try_stream(accept, converted_stream).into_response(); } else if input.input_type.is_m3u() { let Some(channels) = iter_raw_m3u_input_playlist(&app_state.app_config, input, Some(cluster)).await else { return empty_json_list_response(); }; - let converted_stream = channels.map(|entry| entry.map(UiPlaylistItem::from)); + let converted_stream = channels.map(move |entry| { + entry.map(|item| rewrite_resource_url(&encrypt_secret, &resource_url, UiPlaylistItem::from(item))) + }); return stream_json_or_bin_response_try_stream(accept, converted_stream).into_response(); } else if input.input_type.is_stalker() { // TODO refactor @@ -175,6 +210,7 @@ pub(in crate::api::endpoints) async fn get_playlist_for_input( .iter() .flat_map(|group| group.channels.iter()) .map(UiPlaylistItem::from) + .map(|item| rewrite_resource_url(&encrypt_secret, &resource_url, item)) .map(|item| rewrite_stalker_playback_url(&encrypt_secret, &resource_url, input.id, item)) .collect(); interner_gc(); @@ -262,12 +298,8 @@ pub(in crate::api::endpoints) async fn get_playlist_for_custom_provider( let input_id = input.id; let converted_stream = tokio_stream::iter(result.into_iter().flat_map(|g| g.channels).map(move |pli| { - rewrite_stalker_playback_url( - &encrypt_secret, - &resource_url, - input_id, - UiPlaylistItem::from(&pli), - ) + let item = rewrite_resource_url(&encrypt_secret, &resource_url, UiPlaylistItem::from(&pli)); + rewrite_stalker_playback_url(&encrypt_secret, &resource_url, input_id, item) })); stream_json_or_bin_response_stream(accept, converted_stream).into_response() } @@ -282,9 +314,17 @@ pub(in crate::api::endpoints) async fn get_playlist_for_custom_provider( mod tests { use super::{rewrite_resource_url, stalker_refresh_pending_response}; use shared::{ - model::{PlaylistItemType, UiPlaylistItem, XtreamCluster}, - utils::{obfuscate_text, Internable}, + model::{PlaylistItemType, ResourceLocator, UiPlaylistItem, XtreamCluster}, + utils::Internable, }; + use tuliprox_core::utils::MAX_RESOURCE_TOKEN_BYTES; + + /// Decodes a rewritten link with the same route-specific decoding the resource route uses. + fn decode_link(secret: &[u8; 16], link: &str) -> ResourceLocator { + let encoded = link.rsplit('/').next().expect("encoded part"); + let token = tuliprox_core::utils::decode_resource_token(secret, encoded).expect("token decodes"); + ResourceLocator::decode(&token.resource).expect("locator decodes") + } fn sample_item(logo: &str) -> UiPlaylistItem { UiPlaylistItem { @@ -335,13 +375,25 @@ mod tests { } #[test] - fn rewrite_resource_url_wraps_external_urls() { + fn rewrite_resource_url_wraps_external_urls_with_their_origin() { let secret = [7u8; 16]; let item = sample_item("https://example.com/poster.jpg"); let rewritten = rewrite_resource_url(&secret, "/api/v1/playlist/resource", item); - let expected_suffix = obfuscate_text(&secret, "https://example.com/poster.jpg"); - assert_eq!(rewritten.logo.as_ref(), format!("/api/v1/playlist/resource/{expected_suffix}")); + let locator = decode_link(&secret, rewritten.logo.as_ref()); + assert_eq!(locator.url.as_ref(), "https://example.com/poster.jpg"); + assert_eq!(locator.input_name.as_ref(), "test"); + } + + #[test] + fn rewrite_resource_url_rejects_oversized_urls() { + let secret = [7u8; 16]; + let long_logo = format!("https://example.com/{}", "a".repeat(MAX_RESOURCE_TOKEN_BYTES)); + let item = sample_item(&long_logo); + + let rewritten = rewrite_resource_url(&secret, "/api/v1/playlist/resource", item); + + assert!(rewritten.logo.is_empty()); } } diff --git a/backend/app/src/api/endpoints/custom_video_stream_api.rs b/backend/app/src/api/endpoints/custom_video_stream_api.rs index bd521d06f..066b3b990 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -507,11 +507,12 @@ mod tests { use super::{cvs_api_register, parse_cvs_standalone_hls_segment_file}; use crate::{ api::model::{ - build_hls_standalone_custom_plan, hls_custom_video_manifest_response_for_access_lease, - ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, CustomVideoStreamType, - DownloadQueue, EventManager, HlsAccessLease, HlsAccessLeaseId, HlsPlaybackFamilyKey, HlsProvisioningState, - HlsProxyManager, HlsRuntimeCustomTailReason, HlsStandaloneCustomAccess, MetadataUpdateManager, - PlaylistStorageState, ProxySessionId, SharedStreamManager, TransportStreamBuffer, UpdateGuard, + build_hls_standalone_custom_plan, empty_resource_client_set, + hls_custom_video_manifest_response_for_access_lease, ActiveProviderManager, ActiveUserManager, AppState, + CancelTokens, ConnectionManager, CustomVideoStreamType, DownloadQueue, EventManager, HlsAccessLease, + HlsAccessLeaseId, HlsPlaybackFamilyKey, HlsProvisioningState, HlsProxyManager, HlsRuntimeCustomTailReason, + HlsStandaloneCustomAccess, MetadataUpdateManager, PlaylistStorageState, ProxySessionId, + SharedStreamManager, TransportStreamBuffer, UpdateGuard, }, model::{ ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, ConfigSource, ConfigTarget, @@ -724,6 +725,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, diff --git a/backend/app/src/api/endpoints/download_api/tests.rs b/backend/app/src/api/endpoints/download_api/tests.rs index c5da1a14c..d0106e916 100644 --- a/backend/app/src/api/endpoints/download_api/tests.rs +++ b/backend/app/src/api/endpoints/download_api/tests.rs @@ -9,9 +9,10 @@ use super::{ }; use crate::{ api::model::{ - recording_notification::LifecycleEvent, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, - ConnectionManager, DownloadControl, DownloadKind, DownloadQueue, DownloadState, EventManager, EventMessage, - FileDownload, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard, + empty_resource_client_set, recording_notification::LifecycleEvent, ActiveProviderManager, ActiveUserManager, + AppState, CancelTokens, ConnectionManager, DownloadControl, DownloadKind, DownloadQueue, DownloadState, + EventManager, EventMessage, FileDownload, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, + UpdateGuard, }, model::{ ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, MediaToolCapabilities, MessageContent, @@ -577,6 +578,7 @@ fn create_test_app_state_with_downloads(downloads: Arc) -> Arc axum::http::StatusCode::NOT_FOUND.into_response(), - Some(url) => { + Some(value) => { + let resolved = match resolve_resource(&app_state.app_config, &value, Some(&m3u_item.input_name)) { + Ok(resolved) => resolved, + Err(err) => { + log_resource_rejection(Some(m3u_item.input_name.as_ref()), &err, &value); + return rejection_status(&err).into_response(); + } + }; + let url = resolved.url; if user.proxy.is_redirect(m3u_item.item_type) || target.is_force_redirect(m3u_item.item_type) { - let input = app_state.app_config.get_input_by_name(&m3u_item.input_name); + let input = resolved + .authorization + .input_name + .as_ref() + .and_then(|input_name| app_state.app_config.get_input_by_name(input_name)); let redirect_url = crate::api::api_utils::resolve_redirect_location(input.as_deref(), &url); match redirect_url { Ok(redirect_url) => { @@ -949,9 +964,15 @@ async fn m3u_api_resource( } } } else { - resource_response(&app_state, ResourceFetchPolicy::Standard, &url, &req_headers, None) - .await - .into_response() + resource_response( + &app_state, + ResourceFetchOptions::cached(resolved.authorization), + &url, + &req_headers, + None, + ) + .await + .into_response() } } } diff --git a/backend/app/src/api/endpoints/v1_api_playlist.rs b/backend/app/src/api/endpoints/v1_api_playlist.rs index 403170063..a7e840c3f 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist.rs @@ -3,14 +3,15 @@ use crate::{ api::{ api_utils::{ - create_api_proxy_user, json_or_bin_response, resource_response, try_option_bad_request, - try_result_bad_request, try_unwrap_body, ResourceFetchPolicy, + create_api_proxy_user, decode_resource_link, json_or_bin_response, log_resource_rejection, + rejection_status, resolve_resource, resource_response, try_option_bad_request, try_result_bad_request, + try_unwrap_body, ResourceFetchOptions, }, auth_middleware::{check_permission, permission_layer, VerifiedClaims}, endpoints::{ api_playlist_utils::{ get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target, - STALKER_RESOURCE_SCHEME, + rewrite_resource_url, STALKER_RESOURCE_SCHEME, }, extract_accept_header::ExtractAcceptHeader, m3u_api::m3u_api_stream_loaded, @@ -54,9 +55,9 @@ use shared::{ error::TuliproxError, foundation::{get_filter_detailed, Filter, ValueProvider}, model::{ - permission::Permission, stalker::StalkerStreamKind, EpgChannel, InputPlaylistUpdateStatusDto, InputType, - InputUpdateAction, InputUpdateCapabilities, InputUpdateRequest, OperationRunAccepted, - PersistedPlaylistUpdateClusterState, PersistedPlaylistUpdateClusterStatusDto, + ingest_resource_value, permission::Permission, stalker::StalkerStreamKind, EpgChannel, + InputPlaylistUpdateStatusDto, InputType, InputUpdateAction, InputUpdateCapabilities, InputUpdateRequest, + OperationRunAccepted, PersistedPlaylistUpdateClusterState, PersistedPlaylistUpdateClusterStatusDto, PersistedPlaylistUpdateInputResult, PlaylistEpgRequest, PlaylistItem, PlaylistRequest, PlaylistUpdateRequestDto, PlaylistUpdateRequestPayload, PlaylistUpdateRunId, PlaylistUpdateState, PlaylistUpdateStatusDto, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem, VirtualId, @@ -466,7 +467,17 @@ async fn load_epg_channels_for_input( }; let source_channels = match source_result { - Ok(channels) => channels, + Ok(channels) => channels + .into_iter() + .map(|mut channel| { + if let Some(icon) = &mut channel.icon { + if ingest_resource_value(icon, &input.name).is_err() { + channel.icon = None; + } + } + channel + }) + .collect(), Err(err) => { debug!( "Skipping EPG source {}: {}", @@ -1089,15 +1100,36 @@ async fn playlist_resource( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let encrypt_secret = app_state.get_encrypt_secret(); - if let Ok(resource_url) = deobfuscate_text(&encrypt_secret, &resource) { - if let Some((input_id, cluster, provider_id)) = parse_stalker_resource(&resource_url) { - return stalker_resource_response(&app_state, input_id, cluster, provider_id).await; + // This route decodes only its own two formats: the authenticated token for new links, and the + // XOR-based `deobfuscate_text` encoding for links issued before origin tracking. + let decoded = decode_resource_link(&encrypt_secret, &resource, |secret, value| { + deobfuscate_text(secret, value).map_err(|_| ()) + }); + + let resource_value = match decoded { + Ok(decoded) => decoded, + Err(status) => return status.into_response(), + }; + + // Stalker locators are playback references resolved by a dedicated path, not resource URLs. + if let Some((input_id, cluster, provider_id)) = parse_stalker_resource(&resource_value) { + return stalker_resource_response(&app_state, input_id, cluster, provider_id).await; + } + + match resolve_resource(&app_state.app_config, &resource_value, None) { + Ok(resolved) => resource_response( + &app_state, + ResourceFetchOptions::cached(resolved.authorization), + &resolved.url, + &req_headers, + None, + ) + .await + .into_response(), + Err(err) => { + log_resource_rejection(None, &err, &resource_value); + rejection_status(&err).into_response() } - resource_response(&app_state, ResourceFetchPolicy::Standard, &resource_url, &req_headers, None) - .await - .into_response() - } else { - axum::http::StatusCode::BAD_REQUEST.into_response() } } @@ -1391,7 +1423,17 @@ async fn playlist_episode_item( ) .await { - return axum::Json(json!(UiPlaylistItem::from(pli))).into_response(); + let config = app_state.app_config.config.load(); + let web_ui_path = + config.web_ui.as_ref().and_then(|web_ui| web_ui.path.as_ref()).map_or("", String::as_str); + let resource_url = + shared::utils::concat_path_leading_slash(web_ui_path, "api/v1/playlist/resource"); + let item = rewrite_resource_url( + &app_state.get_encrypt_secret(), + &resource_url, + UiPlaylistItem::from(pli), + ); + return axum::Json(json!(item)).into_response(); } } } @@ -1405,9 +1447,9 @@ mod tests { use super::resolve_provider_url_for_request; use crate::{ api::model::{ - recording::recording_source_resolution::resolve_recording_config, ActiveProviderManager, ActiveUserManager, - AppState, ConnectionManager, DownloadQueue, EventManager, MetadataUpdateManager, PlaylistStorageState, - SharedStreamManager, + empty_resource_client_set, recording::recording_source_resolution::resolve_recording_config, + ActiveProviderManager, ActiveUserManager, AppState, ConnectionManager, DownloadQueue, EventManager, + MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, }, model::{ AppConfig, Config, ConfigInput, ConfigInputOptions, ConfigInputUpdateQuality, ConfigProvider, ConfigSource, @@ -2627,6 +2669,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(crate::api::model::DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, diff --git a/backend/app/src/api/endpoints/xmltv_api.rs b/backend/app/src/api/endpoints/xmltv_api.rs index 69de69cde..f4a6e0d6f 100644 --- a/backend/app/src/api/endpoints/xmltv_api.rs +++ b/backend/app/src/api/endpoints/xmltv_api.rs @@ -1,9 +1,10 @@ use crate::{ api::{ api_utils::{ - coalesce_byte_stream, create_api_proxy_user, empty_json_response_as_array, get_user_target, - get_user_target_by_credentials, internal_server_error, resource_response, - stream_json_or_bin_response_try_stream, try_unwrap_body, ResourceFetchPolicy, + coalesce_byte_stream, create_api_proxy_user, decode_resource_link, empty_json_response_as_array, + get_user_target, get_user_target_by_credentials, internal_server_error, log_resource_rejection, + rejection_status, resolve_resource, resource_response, stream_json_or_bin_response_try_stream, + try_unwrap_body, ResourceFetchOptions, }, model::{AppState, UserApiRequest, UserApiRequestQueryOrBody}, static_headers::CT_XML, @@ -20,20 +21,21 @@ use crate::{ }, utils, utils::{ - canonicalize_output_epg_id, canonicalize_untrusted_epg_id, deobscure_text, file_exists_async, - format_xmltv_time_utc, get_epg_processing_options, lowercase_xmltv_text, obscure_text, EpgIdOutputCase, - EpgProcessingOptions, EpgTimeShift, + canonicalize_output_epg_id, canonicalize_untrusted_epg_id, deobscure_text, encode_resource_token, + file_exists_async, format_xmltv_time_utc, get_epg_processing_options, lowercase_xmltv_text, obscure_text, + EpgIdOutputCase, EpgProcessingOptions, EpgTimeShift, }, }; use axum::response::IntoResponse; use chrono::{DateTime, TimeZone}; -use log::{error, trace}; +use log::{debug, error, trace}; use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; use shared::{ concat_string, model::{ - ConfigTargetOptions, EpgChannel, EpgProgramme, EpgProgrammeDto, ShortEpgDto, ShortEpgResultDto, StreamEpgEntry, - StreamEpgItemRequest, StreamEpgRequest, StreamEpgResponse, TargetType, + resolve_resource_value, ConfigTargetOptions, EpgChannel, EpgProgramme, EpgProgrammeDto, ResourceToken, + ShortEpgDto, ShortEpgResultDto, StreamEpgEntry, StreamEpgItemRequest, StreamEpgRequest, StreamEpgResponse, + TargetType, }, utils::{concat_path, concat_path_leading_slash, obfuscate_text, Internable}, }; @@ -205,10 +207,38 @@ pub fn rewrite_epg_channel_resource_url( if icon.is_empty() || icon.starts_with('/') { return channel; } - channel.icon = Some(concat_path(resource_url, &obfuscate_text(encrypt_secret, icon)).intern()); + let encoded = encode_resource_link(encrypt_secret, icon).unwrap_or_else(|| { + let external = external_resource_url(icon); + obfuscate_text(encrypt_secret, external.as_ref()) + }); + channel.icon = Some(concat_path(resource_url, &encoded).intern()); channel } +/// Encodes a resource link that carries its origin. +/// +/// `None` means the link could not carry the origin, for example because the URL is longer than a +/// token may be. Those links keep the legacy encoding and are therefore public-only, which fails +/// closed instead of authorizing an unchecked destination. +pub fn encode_resource_link(encrypt_secret: &[u8; 16], resource: &str) -> Option { + let token = ResourceToken { resource: resource.to_string() }; + match encode_resource_token(encrypt_secret, &token) { + Ok(encoded) => Some(encoded), + Err(err) => { + debug!("Falling back to a legacy resource link: {err}"); + None + } + } +} + +fn external_resource_url(value: &str) -> Cow<'_, str> { + match resolve_resource_value(value) { + Ok(Some(locator)) => Cow::Owned(locator.url.to_string()), + Ok(None) => Cow::Borrowed(value), + Err(_) => Cow::Borrowed(""), + } +} + macro_rules! continue_on_err { ($expr:expr) => { if let Err(_err) = $expr { @@ -256,12 +286,15 @@ fn rewrite_xmltv_icon_url<'a>( ) -> Cow<'a, str> { if epg_processing_options.rewrite_urls { if let Some(base) = base_url { + if let Some(encoded) = encode_resource_link(&epg_processing_options.encrypt_secret, icon_url) { + return Cow::Owned(concat_string!(base, "/", &encoded)); + } if let Ok(enc) = obscure_text(&epg_processing_options.encrypt_secret, icon_url) { return Cow::Owned(concat_string!(base, "/", &enc)); } } } - Cow::Borrowed(icon_url) + external_resource_url(icon_url) } #[allow(clippy::too_many_lines)] @@ -916,12 +949,31 @@ async fn epg_api_resource( } let encrypt_secret = app_state.get_encrypt_secret(); - if let Ok(resource_url) = deobscure_text(&encrypt_secret, &resource) { - resource_response(&app_state, ResourceFetchPolicy::PublicNoRedirect, &resource_url, &req_headers, None) - .await - .into_response() - } else { - axum::http::StatusCode::BAD_REQUEST.into_response() + // This route decodes only its own two formats: the authenticated token for new links, and the + // AES-based `obscure_text` encoding for links issued before origin tracking. + let decoded = + decode_resource_link(&encrypt_secret, &resource, |secret, value| deobscure_text(secret, value).map_err(|_| ())); + + let resource_value = match decoded { + Ok(decoded) => decoded, + Err(status) => return status.into_response(), + }; + + // Legacy links and EPG icons without a recorded origin stay public-only. + match resolve_resource(&app_state.app_config, &resource_value, None) { + Ok(resolved) => resource_response( + &app_state, + ResourceFetchOptions::epg(resolved.authorization), + &resolved.url, + &req_headers, + None, + ) + .await + .into_response(), + Err(err) => { + log_resource_rejection(None, &err, &resource_value); + rejection_status(&err).into_response() + } } } @@ -962,7 +1014,7 @@ mod tests { }, processing::parser::ics::parse_ics_file_to_channel, repository::{epg_write_file, BPlusTree}, - utils::{deobscure_text, lowercase_xmltv_text, EpgIdOutputCase, EpgProcessingOptions, EpgTimeShift}, + utils::{lowercase_xmltv_text, EpgIdOutputCase, EpgProcessingOptions, EpgTimeShift}, }; use arc_swap::ArcSwapOption; use axum::response::IntoResponse; @@ -971,9 +1023,9 @@ mod tests { foundation::Filter, model::{ ConfigTargetOptions, EpgCategory, EpgChannel, EpgOutputOptions, EpgProgramme, ProcessingOrder, - StreamEpgItemRequest, StreamEpgRequest, TargetType, + ResourceLocator, StreamEpgItemRequest, StreamEpgRequest, TargetType, }, - utils::{concat_path, obfuscate_text, Internable}, + utils::Internable, }; use std::{ collections::HashMap, @@ -984,6 +1036,7 @@ mod tests { }; use tempfile::tempdir; use tokio::io::AsyncWrite; + use tuliprox_core::utils::{decode_resource_token, has_resource_token_prefix}; struct ErroringWriter; @@ -1520,7 +1573,9 @@ mod tests { let base_url = "http://localhost/epg/user/password"; let original_url = "https://example.com/programme.jpg"; let mut programme = EpgProgramme::new(100, 200, "channel".intern()); - programme.icon = Some(original_url.intern()); + programme.icon = Some( + ResourceLocator::new("epg-input".into(), original_url.into()).expect("locator").encode().expect("encode"), + ); let mut writer = quick_xml::Writer::new(Vec::new()); write_programme_metadata_tags(&mut writer, &programme, &options, Some(base_url)).await?; let xml = String::from_utf8(writer.into_inner())?; @@ -1529,7 +1584,10 @@ mod tests { .and_then(|value| value.strip_suffix(r#""/>"#)) .unwrap_or_default(); - assert_eq!(deobscure_text(&secret, resource)?, original_url); + let token = decode_resource_token(&secret, resource).expect("token decodes"); + let locator = ResourceLocator::decode(&token.resource).expect("locator decodes"); + assert_eq!(locator.url.as_ref(), original_url); + assert_eq!(locator.input_name.as_ref(), "epg-input"); Ok(()) } @@ -1847,17 +1905,26 @@ mod tests { } #[test] - fn rewrite_epg_channel_resource_url_wraps_external_icon() { + fn rewrite_epg_channel_resource_url_wraps_external_icon_with_its_origin() { let secret = [9u8; 16]; let resource_url = "/api/v1/playlist/resource"; - let channel = sample_channel(Some("https://cdn.example.com/logo.png")); + let mut channel = sample_channel(None); + channel.icon = Some( + ResourceLocator::new("epg-input".into(), "https://cdn.example.com/logo.png".into()) + .expect("locator") + .encode() + .expect("encode"), + ); let rewritten = rewrite_epg_channel_resource_url(&secret, resource_url, channel); - assert_eq!( - rewritten.icon.as_deref(), - Some(concat_path(resource_url, &obfuscate_text(&secret, "https://cdn.example.com/logo.png")).as_str()) - ); + let link = rewritten.icon.as_deref().expect("rewritten icon"); + let encoded = link.rsplit('/').next().expect("encoded part"); + assert!(has_resource_token_prefix(encoded)); + let token = decode_resource_token(&secret, encoded).expect("token decodes"); + let locator = ResourceLocator::decode(&token.resource).expect("locator decodes"); + assert_eq!(locator.url.as_ref(), "https://cdn.example.com/logo.png"); + assert_eq!(locator.input_name.as_ref(), "epg-input"); } #[test] diff --git a/backend/app/src/api/endpoints/xtream_api/mod.rs b/backend/app/src/api/endpoints/xtream_api/mod.rs index b5a50b96e..2faa7de74 100644 --- a/backend/app/src/api/endpoints/xtream_api/mod.rs +++ b/backend/app/src/api/endpoints/xtream_api/mod.rs @@ -10,9 +10,10 @@ use crate::{ empty_json_response_as_array, empty_json_response_as_object, force_provider_stream_response, get_session_reservation_ttl_secs, get_user_target, get_user_target_by_credentials, internal_server_error, is_seekable_media_request, is_session_based_playback, is_stream_share_enabled, local_stream_response, - redirect, redirect_response, reentry_suppressed_response, resolve_initial_stalker_playback_url, - resource_response, separate_number_and_remainder, should_allow_exhausted_shared_reconnect, stream_response, - try_option_bad_request, try_result_bad_request, try_unwrap_body, RedirectParams, ResourceFetchPolicy, + log_resource_rejection, redirect, redirect_response, reentry_suppressed_response, rejection_status, + resolve_initial_stalker_playback_url, resolve_resource, resource_response, separate_number_and_remainder, + should_allow_exhausted_shared_reconnect, stream_response, try_option_bad_request, try_result_bad_request, + try_unwrap_body, RedirectParams, ResourceFetchOptions, }, endpoints::{ hls_api::{ @@ -1073,13 +1074,28 @@ async fn xtream_player_api_resource( return axum::http::StatusCode::NOT_FOUND.into_response(); } + if !shared::model::is_resource_field_name(resource) { + return axum::http::StatusCode::NOT_FOUND.into_response(); + } let stream_url = pli.resolve_resource_url(resource); match stream_url { None => axum::http::StatusCode::NOT_FOUND.into_response(), - Some(url) => { + Some(value) => { + let resolved = match resolve_resource(&app_state.app_config, &value, Some(&pli.input_name)) { + Ok(resolved) => resolved, + Err(err) => { + log_resource_rejection(Some(pli.input_name.as_ref()), &err, &value); + return rejection_status(&err).into_response(); + } + }; + let url = resolved.url; if user.proxy.is_redirect(pli.item_type) || target.is_force_redirect(pli.item_type) { - let input = app_state.app_config.get_input_by_name(&pli.input_name); + let input = resolved + .authorization + .input_name + .as_ref() + .and_then(|input_name| app_state.app_config.get_input_by_name(input_name)); let redirect_url = api_utils::resolve_redirect_location(input.as_deref(), &url); match redirect_url { Ok(redirect_url) => { @@ -1096,9 +1112,15 @@ async fn xtream_player_api_resource( } } else { trace_if_enabled!("Resource request to {}", sanitize_sensitive_info(&url)); - resource_response(app_state, ResourceFetchPolicy::Standard, &url, req_headers, None) - .await - .into_response() + resource_response( + app_state, + ResourceFetchOptions::cached(resolved.authorization), + &url, + req_headers, + None, + ) + .await + .into_response() } } } diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index bf973ce9f..df175338f 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -19,7 +19,7 @@ use crate::{ http_layers::create_cors_layer, model::{ create_cache, create_http_client, create_http_client_no_redirect, create_public_http_client_no_redirect, - exec_provider_dns, exec_qos_aggregation, load_playlists_into_memory_cache, + create_resource_client_set, exec_provider_dns, exec_qos_aggregation, load_playlists_into_memory_cache, recording_rule_scheduler::spawn_recording_rule_scheduler, recording_supervisor::start_recording_supervisors, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, EventMessage, HdHomerunAppState, @@ -430,6 +430,7 @@ async fn create_shared_data( let client = create_http_client(app_config)?; let client_no_redirect = create_http_client_no_redirect(app_config)?; let public_client_no_redirect = create_public_http_client_no_redirect(app_config)?; + let resource_clients = create_resource_client_set(app_config)?; let tokens = CancelTokens::default(); let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); @@ -450,6 +451,7 @@ async fn create_shared_data( http_client: Arc::new(ArcSwap::from_pointee(client)), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(client_no_redirect)), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(public_client_no_redirect)), + resource_clients: Arc::new(ArcSwap::from_pointee(resource_clients)), downloads: Arc::new(DownloadQueue::new_with_state_file(Some(downloads_state_file))), cache: Arc::new(ArcSwapOption::from(cache)), shared_stream_manager, @@ -1121,9 +1123,10 @@ mod tests { use super::super::ready; use crate::{ api::model::{ - ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionKind, ConnectionManager, - DownloadQueue, EventManager, HlsProvisioningState, HlsProxyManager, ManualPlaylistUpdateRequest, - MetadataUpdateManager, PlaylistStorageState, ProviderHandle, SharedStreamManager, UpdateGuard, + empty_resource_client_set, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, + ConnectionKind, ConnectionManager, DownloadQueue, EventManager, HlsProvisioningState, HlsProxyManager, + ManualPlaylistUpdateRequest, MetadataUpdateManager, PlaylistStorageState, ProviderHandle, + SharedStreamManager, UpdateGuard, }, model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, ProcessTargets, SourcesConfig}, repository::GeoIp, @@ -1222,6 +1225,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, diff --git a/backend/app/src/api/model/app_state.rs b/backend/app/src/api/model/app_state.rs index 25e0904e5..b327fae60 100644 --- a/backend/app/src/api/model/app_state.rs +++ b/backend/app/src/api/model/app_state.rs @@ -16,7 +16,7 @@ use crate::{ repository::{get_geoip_path, GeoIp}, utils::{ reload_logger, - request::{create_client, create_client_with_redirect, PublicIpResolver}, + request::{create_client, create_client_with_redirect, create_resource_http_client, PublicIpResolver}, LRUResourceCache, }, }; @@ -36,6 +36,7 @@ use std::{ }; use tokio::sync::{mpsc, RwLock}; use tokio_util::sync::CancellationToken; +use tuliprox_core::model::{public_only_policy, PolicyDigest, ResourceClientKey, ResourcePolicy, ResourceRedirectMode}; use tuliprox_hls::api::HlsProxyManager; use tuliprox_metadata::manager::MetadataUpdateManager; use tuliprox_repository::{identity_registry::IdentityRegistry, token_revocations::TokenRevocations}; @@ -318,6 +319,71 @@ pub fn create_public_http_client_no_redirect(app_config: &AppConfig) -> Result, + policies: HashMap>, +} + +impl ResourceClientSet { + /// Client for a policy digest and redirect mode, or `None` when the policy is not configured + /// any more. A miss is answered with a rejection, never with a broader fallback. + pub fn client(&self, digest: &PolicyDigest, mode: ResourceRedirectMode) -> Option<&Client> { + self.clients.get(&ResourceClientKey::new(digest.clone(), mode)) + } + + pub fn policy(&self, digest: &PolicyDigest) -> Option<&Arc> { self.policies.get(digest) } + + pub fn is_empty(&self) -> bool { self.clients.is_empty() } + + /// Set with pre-built clients, for tests that inject a mock transport. + #[cfg(test)] + pub fn from_clients(clients: HashMap) -> Self { + Self { clients, policies: HashMap::new() } + } +} + +/// Empty client set, used by states that never exercise the resource proxy. +pub fn empty_resource_client_set() -> Arc> { + Arc::new(ArcSwap::from_pointee(ResourceClientSet::default())) +} + +/// Builds the resource client set for the currently configured inputs. +/// +/// Only primary inputs carry a policy; aliases inherit it, and every other input shares the +/// public-only client. +pub fn create_resource_client_set(app_config: &AppConfig) -> Result { + let config = app_config.config.load(); + tuliprox_core::utils::request::warn_resource_proxy_bypass(&config); + drop(config); + + let sources = app_config.sources.load(); + let mut policies: HashMap> = HashMap::new(); + let public_only = public_only_policy(); + policies.insert(public_only.digest(), Arc::clone(&public_only)); + for input in &sources.inputs { + if let Some(policy) = input.resource_policy.as_ref().filter(|policy| !policy.is_empty()) { + policies.entry(policy.digest()).or_insert_with(|| Arc::clone(policy)); + } + } + drop(sources); + + let mut clients = HashMap::with_capacity(policies.len() * 2); + for policy in policies.values() { + for redirect_mode in [ResourceRedirectMode::NoRedirect, ResourceRedirectMode::Bounded] { + let client = create_resource_http_client(app_config, Arc::clone(policy), redirect_mode)?; + clients.insert(ResourceClientKey::new(policy.digest(), redirect_mode), client); + } + } + Ok(ResourceClientSet { clients, policies }) +} + fn build_http_client_with_fallback( mut builder: reqwest::ClientBuilder, config: &Arc, @@ -430,6 +496,8 @@ pub struct AppState { pub http_client: Arc>, pub http_client_no_redirect: Arc>, pub public_http_client_no_redirect: Arc>, + /// Policy- and redirect-mode-keyed clients used by the resource proxy. + pub resource_clients: Arc>, pub downloads: Arc, pub cache: Arc>>, pub shared_stream_manager: Arc, @@ -531,6 +599,7 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { http_client: Arc::new(ArcSwap::from_pointee(Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), + resource_clients: Arc::new(ArcSwap::from_pointee(ResourceClientSet::default())), downloads: Arc::new(DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, @@ -608,6 +677,7 @@ impl AppState { self.http_client_no_redirect.store(Arc::new(client_no_redirect)); let public_client_no_redirect = create_public_http_client_no_redirect(&self.app_config)?; self.public_http_client_no_redirect.store(Arc::new(public_client_no_redirect)); + self.resource_clients.store(Arc::new(create_resource_client_set(&self.app_config)?)); // cache let config = self.app_config.config.load(); @@ -650,6 +720,7 @@ impl AppState { } self.app_config.set_sources(sources)?; self.active_provider.update_config(&self.app_config); + self.resource_clients.store(Arc::new(create_resource_client_set(&self.app_config)?)); shared::model::REGEX_CACHE.sweep(); Ok(changes) diff --git a/backend/app/src/api/model/streams/active_client_stream.rs b/backend/app/src/api/model/streams/active_client_stream.rs index 7df5f8682..766aa747c 100644 --- a/backend/app/src/api/model/streams/active_client_stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream.rs @@ -1587,11 +1587,11 @@ mod tests { }; use crate::{ api::model::{ - connection_manager::PROVIDER_END_NOT_SET, ActiveProviderManager, ActiveUserManager, AppState, - BoxedProviderStream, CancelTokens, ConnectionManager, CreateUserSessionParams, CustomVideoStreamType, - DownloadQueue, EventManager, GraceResolutionContext, MetadataUpdateManager, PlaylistStorageState, - ProviderContentRepresentationMode, ProviderHandle, SharedStreamManager, StreamDetails, StreamError, - UpdateGuard, + connection_manager::PROVIDER_END_NOT_SET, empty_resource_client_set, ActiveProviderManager, + ActiveUserManager, AppState, BoxedProviderStream, CancelTokens, ConnectionManager, CreateUserSessionParams, + CustomVideoStreamType, DownloadQueue, EventManager, GraceResolutionContext, MetadataUpdateManager, + PlaylistStorageState, ProviderContentRepresentationMode, ProviderHandle, SharedStreamManager, + StreamDetails, StreamError, UpdateGuard, }, auth::Fingerprint, model::{ @@ -1719,6 +1719,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, @@ -1798,6 +1799,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(Client::new())), + resource_clients: empty_resource_client_set(), downloads: Arc::new(DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, diff --git a/backend/core/src/model/config/input.rs b/backend/core/src/model/config/input.rs index 19d2efbd7..03626c9f1 100644 --- a/backend/core/src/model/config/input.rs +++ b/backend/core/src/model/config/input.rs @@ -1,5 +1,5 @@ use crate::{ - model::{macros, ConfigInputUpdateQuality, ConfigProvider, EpgConfig, PanelApiConfig}, + model::{macros, ConfigInputUpdateQuality, ConfigProvider, EpgConfig, PanelApiConfig, ResourcePolicy}, utils::get_csv_file_path, }; use chrono::Utc; @@ -506,6 +506,9 @@ pub struct ConfigInput { pub provider_configs: Option>>, /// Resolved Stalker device identity + portal hints. pub stalker: Option, + /// Trusted private destinations for resource URLs supplied by this input. `None` and an + /// empty policy are equivalent: public destinations only. + pub resource_policy: Option>, } impl ConfigInput { @@ -894,6 +897,8 @@ impl ConfigInput { let password = alias.password.clone().or_else(|| cfg.password.clone()); StalkerInputConfig { username, password, ..cfg } }), + // An alias serves the same resources as its main input, so it inherits the policy. + resource_policy: self.resource_policy.clone(), } } @@ -986,6 +991,13 @@ impl From<&ConfigInputDto> for ConfigInput { .stalker .as_ref() .map(|s| StalkerInputConfig::from(s).with_credentials(dto.username.as_ref(), dto.password.as_ref())), + // An invalid policy is rejected while the sources config is built; dropping it here + // fails closed to public-only instead of silently trusting a partial allowlist. + resource_policy: dto + .resource_policy + .as_ref() + .and_then(|policy| ResourcePolicy::from_dto(policy).ok()) + .map(Arc::new), } } } @@ -1143,6 +1155,45 @@ mod tests { assert_update_quality_is_preserved(&alias_input); } + #[test] + fn alias_conversion_inherits_the_resource_policy() { + let dto = ConfigInputDto { + input_type: InputType::M3u, + url: "https://provider.example/playlist.m3u".to_string(), + resource_policy: Some(shared::model::ResourcePolicyDto { + allowed_hosts: vec!["media.home.arpa".to_string()], + allowed_networks: vec!["192.168.50.20/32".to_string()], + }), + aliases: Some(vec![ConfigInputAliasDto { + name: "alias".into(), + url: "https://alias.example.invalid".to_string(), + ..ConfigInputAliasDto::default() + }]), + ..ConfigInputDto::default() + }; + let input = ConfigInput::from(&dto); + let alias = input.aliases.as_ref().and_then(|aliases| aliases.first()).expect("runtime alias"); + + let alias_input = input.as_input(alias); + + let policy = alias_input.resource_policy.expect("alias inherits the policy"); + assert!(policy.allows_host("media.home.arpa")); + } + + #[test] + fn an_invalid_resource_policy_converts_to_public_only() { + // The load path rejects this configuration; the conversion itself must fail closed. + let dto = ConfigInputDto { + resource_policy: Some(shared::model::ResourcePolicyDto { + allowed_hosts: vec!["not a host".to_string()], + allowed_networks: Vec::new(), + }), + ..ConfigInputDto::default() + }; + + assert!(ConfigInput::from(&dto).resource_policy.is_none()); + } + #[test] fn batch_conversion_preserves_update_quality_when_promoting_alias() { let mut input = ConfigInput::from(&ConfigInputDto { diff --git a/backend/core/src/model/config/mod.rs b/backend/core/src/model/config/mod.rs index 85962dfae..cf66b8e1c 100644 --- a/backend/core/src/model/config/mod.rs +++ b/backend/core/src/model/config/mod.rs @@ -23,6 +23,7 @@ mod proxy; mod qos_aggregation; mod rate_limit; mod rename; +mod resource_policy; mod reverse_proxy; mod schedule; mod sort; @@ -58,6 +59,7 @@ pub use proxy::*; pub use qos_aggregation::*; pub use rate_limit::*; pub use rename::*; +pub use resource_policy::*; pub use reverse_proxy::*; pub use schedule::*; pub use sort::*; diff --git a/backend/core/src/model/config/resource_policy.rs b/backend/core/src/model/config/resource_policy.rs new file mode 100644 index 000000000..e10f3adf0 --- /dev/null +++ b/backend/core/src/model/config/resource_policy.rs @@ -0,0 +1,486 @@ +use crate::utils::{ + encode_base64_string, + network::request::{canonicalize_ip, classify_ip, AddressClass}, +}; +use ipnet::IpNet; +use shared::{error::TuliproxError, model::ResourcePolicyDto}; +use std::{ + fmt, + hash::{Hash, Hasher}, + net::IpAddr, + sync::{Arc, LazyLock}, +}; +use url::{Host, Url}; + +/// Domain separator for policy digests. Bumping it invalidates every persisted cache entry and +/// client identity that was derived from a policy. +const POLICY_DIGEST_DOMAIN: &[u8] = b"tuliprox.resource-policy.v1"; + +/// The networks a policy may authorize. Anything outside this list is either public or always +/// blocked, so accepting a broader range here could never grant access — it would only widen the +/// address space an operator can accidentally authorize. +const ALLOWED_PRIVATE_NETWORKS: &[&str] = &["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]; + +/// Which destinations the resource proxy accepts for one input. +/// +/// An empty policy is public-only and is the normalized representation of "no exception +/// configured". Normalization is part of the contract: two configurations that normalize to the +/// same hosts and networks are equal, share one HTTP client, and share one cache identity. +#[derive(Debug, Clone, Default)] +pub struct ResourcePolicy { + pub allowed_hosts: Arc<[Arc]>, + pub allowed_networks: Arc<[IpNet]>, +} + +impl PartialEq for ResourcePolicy { + fn eq(&self, other: &Self) -> bool { self.digest() == other.digest() } +} + +impl Eq for ResourcePolicy {} + +impl Hash for ResourcePolicy { + fn hash(&self, state: &mut H) { self.digest().hash(state); } +} + +impl ResourcePolicy { + #[inline] + pub fn is_empty(&self) -> bool { self.allowed_hosts.is_empty() && self.allowed_networks.is_empty() } + + /// Stable identity of the normalized policy: the client key and the cache scope. + pub fn digest(&self) -> PolicyDigest { + let mut hasher = blake3::Hasher::new(); + hasher.update(POLICY_DIGEST_DOMAIN); + hasher.update(&(self.allowed_hosts.len() as u64).to_be_bytes()); + for host in self.allowed_hosts.iter() { + hasher.update(&(host.len() as u64).to_be_bytes()); + hasher.update(host.as_bytes()); + } + hasher.update(&(self.allowed_networks.len() as u64).to_be_bytes()); + for network in self.allowed_networks.iter() { + let network = network.to_string(); + hasher.update(&(network.len() as u64).to_be_bytes()); + hasher.update(network.as_bytes()); + } + PolicyDigest(encode_base64_string(&hasher.finalize().as_bytes()[..16]).into()) + } + + /// Digest of the public-only policy. Every request without an exception uses it, so the + /// cache scope of legacy and unconfigured inputs is one shared identity. + pub fn public_only_digest() -> PolicyDigest { public_only_policy().digest() } + + /// Exact host name match, case-insensitive because host names are stored normalized. + pub fn allows_host(&self, host: &str) -> bool { self.allowed_hosts.iter().any(|allowed| allowed.as_ref() == host) } + + pub fn allows_network(&self, address: IpAddr) -> bool { + let address = canonicalize_ip(address); + self.allowed_networks.iter().any(|network| network.contains(&address)) + } + + /// Decision for an address that came out of DNS resolution for `host`. + pub fn authorize_resolved(&self, host: &str, address: IpAddr) -> Result<(), ResourcePolicyError> { + match classify_ip(address) { + AddressClass::Blocked => Err(ResourcePolicyError::BlockedAddress), + AddressClass::Public => Ok(()), + AddressClass::Private => { + if !self.allows_host(host) { + return Err(ResourcePolicyError::HostNotTrusted); + } + if !self.allows_network(address) { + return Err(ResourcePolicyError::NetworkNotTrusted); + } + Ok(()) + } + } + } + + /// Decision for an IP literal inside a URL. A literal has no host name to match, so only the + /// network policy can authorize it and the literal itself is the destination. + pub fn authorize_literal(&self, address: IpAddr) -> Result<(), ResourcePolicyError> { + match classify_ip(address) { + AddressClass::Blocked => Err(ResourcePolicyError::BlockedAddress), + AddressClass::Public => Ok(()), + AddressClass::Private if self.allows_network(address) => Ok(()), + AddressClass::Private => Err(ResourcePolicyError::NetworkNotTrusted), + } + } + + /// Validates scheme, host presence, and IP literals of a URL before the first request. + /// + /// Host names are deliberately not resolved here: `PolicyIpResolver` is the single + /// connection-time enforcement point, so DNS rebinding protection and the single lookup stay + /// intact. An IP literal never reaches the resolver, which is why it is classified here. + pub fn validate_initial_url(&self, url: &Url) -> Result<(), ResourcePolicyError> { + if !matches!(url.scheme(), "http" | "https") { + return Err(ResourcePolicyError::UnsupportedScheme); + } + match url.host().ok_or(ResourcePolicyError::MissingHost)? { + Host::Ipv4(address) => self.authorize_literal(IpAddr::V4(address)), + Host::Ipv6(address) => self.authorize_literal(IpAddr::V6(address)), + Host::Domain(_) => Ok(()), + } + } + + /// Normalizes and validates a configured policy. + pub fn from_dto(dto: &ResourcePolicyDto) -> Result { + let allowed_hosts = normalize_hosts(&dto.allowed_hosts)?; + let allowed_networks = normalize_networks(&dto.allowed_networks)?; + Ok(Self { allowed_hosts: allowed_hosts.into(), allowed_networks: allowed_networks.into() }) + } + + /// Convenience for callers that hold a list of configured inputs. + pub fn from_optional_dto(dto: Option<&ResourcePolicyDto>) -> Result { + dto.map_or_else(|| Ok(Self::default()), Self::from_dto) + } +} + +/// The shared public-only policy instance. +pub fn public_only_policy() -> Arc { + static PUBLIC_ONLY: LazyLock> = LazyLock::new(|| Arc::new(ResourcePolicy::default())); + Arc::clone(&PUBLIC_ONLY) +} + +fn normalize_hosts(hosts: &[String]) -> Result>, TuliproxError> { + let mut normalized: Vec = Vec::with_capacity(hosts.len()); + for host in hosts { + let trimmed = host.trim(); + if trimmed.is_empty() { + return Err(policy_error("host entry must not be empty")); + } + if trimmed.contains("://") || trimmed.contains('/') { + return Err(policy_error(format!("'{trimmed}' is not a host name; remove scheme and path"))); + } + if trimmed.contains('*') { + return Err(policy_error(format!("wildcards are not supported in '{trimmed}'"))); + } + if trimmed.contains(':') { + return Err(policy_error(format!("'{trimmed}' must not contain a port"))); + } + // A DNS name only. IP literals are authorized through `allowed_networks`, and accepting + // them here would create a second, unchecked way to name a destination. + match Host::parse(trimmed).map_err(|_| policy_error(format!("'{trimmed}' is not a valid host name")))? { + Host::Domain(domain) => { + let domain = domain.trim_end_matches('.').to_ascii_lowercase(); + if domain.is_empty() { + return Err(policy_error(format!("'{trimmed}' is not a valid host name"))); + } + normalized.push(domain); + } + Host::Ipv4(_) | Host::Ipv6(_) => { + return Err(policy_error(format!("'{trimmed}' is an IP address; list it in allowed_networks instead"))) + } + } + } + normalized.sort_unstable(); + normalized.dedup(); + Ok(normalized.into_iter().map(Arc::from).collect()) +} + +fn normalize_networks(networks: &[String]) -> Result, TuliproxError> { + let allowed: Vec = + ALLOWED_PRIVATE_NETWORKS.iter().filter_map(|network| network.parse::().ok()).collect(); + let mut normalized: Vec = Vec::with_capacity(networks.len()); + for network in networks { + let trimmed = network.trim(); + let parsed = + trimmed.parse::().map_err(|_| policy_error(format!("'{trimmed}' is not a CIDR network")))?; + let parsed = parsed.trunc(); + // The whole network must be contained in a private range, so a broad entry such as + // 0.0.0.0/0 cannot silently authorize the public internet. + if !is_within_private_range(&parsed, &allowed) { + return Err(policy_error(format!( + "'{trimmed}' is not contained in a private range ({})", + ALLOWED_PRIVATE_NETWORKS.join(", ") + ))); + } + normalized.push(parsed); + } + normalized.sort_unstable(); + normalized.dedup(); + Ok(normalized) +} + +fn is_within_private_range(network: &IpNet, allowed: &[IpNet]) -> bool { + allowed.iter().any(|range| match (range, network) { + (IpNet::V4(range), IpNet::V4(net)) => net.prefix_len() >= range.prefix_len() && range.contains(&net.network()), + (IpNet::V6(range), IpNet::V6(net)) => net.prefix_len() >= range.prefix_len() && range.contains(&net.network()), + _ => false, + }) +} + +fn policy_error(message: impl Into) -> TuliproxError { + TuliproxError::ConfigInput(format!("invalid resource_policy: {}", message.into())) +} + +/// Short, filesystem-safe identity of a normalized policy. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PolicyDigest(Arc); + +impl PolicyDigest { + #[inline] + pub fn as_str(&self) -> &str { &self.0 } +} + +impl fmt::Display for PolicyDigest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } +} + +/// Which redirect strategy a resource client uses. Both modes are needed at the same time: the +/// EPG route returns upstream redirects, the cached resource routes follow a bounded number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ResourceRedirectMode { + NoRedirect, + Bounded, +} + +/// Identifies one resource HTTP client. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResourceClientKey { + pub policy_digest: PolicyDigest, + pub redirect_mode: ResourceRedirectMode, +} + +impl ResourceClientKey { + pub fn new(policy_digest: PolicyDigest, redirect_mode: ResourceRedirectMode) -> Self { + Self { policy_digest, redirect_mode } + } +} + +/// Why a resource request was refused. The variant is kept for diagnostics; the HTTP layer maps +/// every policy rejection to the same status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourcePolicyError { + /// The origin names an input that does not exist or is disabled. + UnknownOrigin(String), + BlockedAddress, + HostNotTrusted, + NetworkNotTrusted, + TooManyRedirects, + UnsupportedScheme, + MissingHost, +} + +impl fmt::Display for ResourcePolicyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownOrigin(name) => write!(f, "origin input '{name}' is unknown or disabled"), + Self::BlockedAddress => f.write_str("destination is an always-blocked address"), + Self::HostNotTrusted => f.write_str("private destination host is not listed in allowed_hosts"), + Self::NetworkNotTrusted => f.write_str("destination address is not inside allowed_networks"), + Self::TooManyRedirects => f.write_str("redirect limit exceeded"), + Self::UnsupportedScheme => f.write_str("unsupported resource URL scheme"), + Self::MissingHost => f.write_str("resource URL has no host"), + } + } +} + +impl std::error::Error for ResourcePolicyError {} + +#[cfg(test)] +mod tests { + use super::{public_only_policy, ResourceClientKey, ResourcePolicy, ResourcePolicyDto, ResourceRedirectMode}; + use std::net::IpAddr; + + fn policy(hosts: &[&str], networks: &[&str]) -> ResourcePolicy { + ResourcePolicy::from_dto(&ResourcePolicyDto { + allowed_hosts: hosts.iter().map(|h| (*h).to_string()).collect(), + allowed_networks: networks.iter().map(|n| (*n).to_string()).collect(), + }) + .expect("policy should be valid") + } + + fn ip(value: &str) -> IpAddr { value.parse().expect("ip address") } + + #[test] + fn identical_policies_share_one_digest() { + let first = policy(&["Media.Home.Arpa."], &["192.168.50.20/32", "10.0.0.0/8"]); + let second = policy(&["media.home.arpa"], &["10.0.0.0/8", "192.168.50.20/32"]); + let third = policy(&["media.home.arpa"], &["10.0.0.0/8", "192.168.50.16/28"]); + + assert_eq!(first.digest(), second.digest()); + assert_eq!(first, second); + assert_ne!(first.digest(), third.digest()); + } + + #[test] + fn empty_policy_is_public_only() { + let empty = ResourcePolicy::default(); + assert!(empty.is_empty()); + assert_eq!(empty.digest(), public_only_policy().digest()); + assert_eq!(empty.authorize_resolved("any.host", ip("8.8.8.8")), Ok(())); + assert_eq!( + empty.authorize_resolved("any.host", ip("10.1.2.3")), + Err(super::ResourcePolicyError::HostNotTrusted) + ); + assert_eq!(empty.authorize_literal(ip("192.168.1.1")), Err(super::ResourcePolicyError::NetworkNotTrusted)); + } + + #[test] + fn private_dns_destination_requires_host_and_network() { + let policy = policy(&["media.home.arpa"], &["192.168.50.20/32"]); + + assert_eq!(policy.authorize_resolved("media.home.arpa", ip("192.168.50.20")), Ok(())); + assert_eq!( + policy.authorize_resolved("media.home.arpa", ip("192.168.50.21")), + Err(super::ResourcePolicyError::NetworkNotTrusted) + ); + assert_eq!( + policy.authorize_resolved("other.home.arpa", ip("192.168.50.20")), + Err(super::ResourcePolicyError::HostNotTrusted) + ); + } + + #[test] + fn ip_literals_are_authorized_by_network_only() { + let policy = policy(&["media.home.arpa"], &["192.168.50.20/32"]); + assert_eq!(policy.authorize_literal(ip("192.168.50.20")), Ok(())); + assert_eq!(policy.authorize_literal(ip("192.168.50.21")), Err(super::ResourcePolicyError::NetworkNotTrusted)); + assert_eq!(policy.authorize_literal(ip("127.0.0.1")), Err(super::ResourcePolicyError::BlockedAddress)); + assert_eq!(policy.authorize_literal(ip("169.254.169.254")), Err(super::ResourcePolicyError::BlockedAddress)); + } + + #[test] + fn mapped_ipv6_is_classified_as_its_ipv4_address() { + let policy = policy(&["media.home.arpa"], &["192.168.50.20/32"]); + assert_eq!(policy.authorize_literal(ip("::ffff:192.168.50.20")), Ok(())); + assert_eq!(policy.authorize_literal(ip("::ffff:10.0.0.1")), Err(super::ResourcePolicyError::NetworkNotTrusted)); + } + + #[test] + fn public_destinations_stay_allowed() { + let policy = policy(&["media.home.arpa"], &["192.168.50.20/32"]); + assert_eq!(policy.authorize_resolved("cdn.example.com", ip("93.184.216.34")), Ok(())); + assert_eq!(policy.authorize_literal(ip("1.1.1.1")), Ok(())); + } + + #[test] + fn blocked_ranges_are_rejected_even_with_a_matching_network() { + // 10.0.0.0/8 covers none of these, but the check must not depend on the policy at all. + let policy = policy(&[], &["10.0.0.0/8"]); + for blocked in ["127.0.0.1", "169.254.1.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "100.64.0.1"] { + assert_eq!( + policy.authorize_literal(ip(blocked)), + Err(super::ResourcePolicyError::BlockedAddress), + "{blocked} must stay blocked" + ); + } + for blocked in ["::1", "fe80::1", "ff02::1", "2001:db8::1"] { + assert_eq!( + policy.authorize_literal(ip(blocked)), + Err(super::ResourcePolicyError::BlockedAddress), + "{blocked} must stay blocked" + ); + } + } + + #[test] + fn ula_addresses_are_private() { + let policy = policy(&["nas.home.arpa"], &["fd00::/64"]); + assert_eq!(policy.authorize_resolved("nas.home.arpa", ip("fd00::5")), Ok(())); + assert_eq!( + policy.authorize_resolved("nas.home.arpa", ip("fd00:1::5")), + Err(super::ResourcePolicyError::NetworkNotTrusted) + ); + } + + #[test] + fn initial_url_validation_checks_scheme_host_and_literals() { + let policy = policy(&["media.home.arpa"], &["192.168.50.20/32"]); + + let allowed = url::Url::parse("http://192.168.50.20/logo.png").expect("url"); + assert_eq!(policy.validate_initial_url(&allowed), Ok(())); + + let rejected = url::Url::parse("http://192.168.1.1/logo.png").expect("url"); + assert_eq!(policy.validate_initial_url(&rejected), Err(super::ResourcePolicyError::NetworkNotTrusted)); + + let blocked = url::Url::parse("http://[::1]/logo.png").expect("url"); + assert_eq!(policy.validate_initial_url(&blocked), Err(super::ResourcePolicyError::BlockedAddress)); + + // Host names are resolved later, by the connection-time resolver. + let name = url::Url::parse("https://media.home.arpa/logo.png").expect("url"); + assert_eq!(policy.validate_initial_url(&name), Ok(())); + + let scheme = url::Url::parse("ftp://media.home.arpa/logo.png").expect("url"); + assert_eq!(policy.validate_initial_url(&scheme), Err(super::ResourcePolicyError::UnsupportedScheme)); + } + + #[test] + fn host_entries_are_validated_and_normalized() { + let dto = ResourcePolicyDto { + allowed_hosts: vec!["Media.Home.Arpa.".to_string(), " media.home.arpa ".to_string()], + allowed_networks: Vec::new(), + }; + let policy = ResourcePolicy::from_dto(&dto).expect("policy"); + assert_eq!(policy.allowed_hosts.len(), 1); + assert!(policy.allows_host("media.home.arpa")); + + for invalid in ["", "https://host/path", "host:8080", "*.home.arpa", "192.168.1.1"] { + let dto = ResourcePolicyDto { allowed_hosts: vec![invalid.to_string()], allowed_networks: Vec::new() }; + assert!(ResourcePolicy::from_dto(&dto).is_err(), "'{invalid}' must be rejected"); + } + } + + #[test] + fn networks_outside_private_ranges_are_rejected() { + for invalid in ["0.0.0.0/0", "8.8.8.0/24", "172.32.0.0/16", "2001:db8::/32", "not-a-network"] { + let dto = ResourcePolicyDto { allowed_hosts: Vec::new(), allowed_networks: vec![invalid.to_string()] }; + assert!(ResourcePolicy::from_dto(&dto).is_err(), "'{invalid}' must be rejected"); + } + + for valid in ["10.0.0.0/8", "172.16.5.0/24", "192.168.50.20/32", "fc00::/7", "fd00::/64"] { + let dto = ResourcePolicyDto { allowed_hosts: Vec::new(), allowed_networks: vec![valid.to_string()] }; + assert!(ResourcePolicy::from_dto(&dto).is_ok(), "'{valid}' must be accepted"); + } + } + + #[tokio::test] + async fn the_resolver_keeps_only_authorized_addresses() { + use crate::utils::network::request::resolve_policy_socket_addrs; + + // A public literal is authorized by every policy, including the empty one. + let addresses = resolve_policy_socket_addrs("1.1.1.1", 80, &ResourcePolicy::default()) + .await + .expect("public literal resolves"); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].port(), 80); + + // Loopback stays blocked even though the policy resolver is the only check here. + let loopback = resolve_policy_socket_addrs("127.0.0.1", 80, &ResourcePolicy::default()).await; + assert_eq!( + loopback.err().map(|err| err.kind()), + Some(std::io::ErrorKind::PermissionDenied), + "loopback must be refused" + ); + + let private = resolve_policy_socket_addrs("192.168.1.1", 80, &ResourcePolicy::default()).await; + assert_eq!( + private.err().map(|err| err.kind()), + Some(std::io::ErrorKind::PermissionDenied), + "a private literal without a policy must be refused" + ); + } + + #[test] + fn loopback_can_never_be_authorized() { + // Loopback is outside every allowed private range, so a policy that tries to authorize it + // is rejected while the configuration is loaded instead of silently accepting it. + let dto = ResourcePolicyDto { + allowed_hosts: vec!["localhost".to_string()], + allowed_networks: vec!["127.0.0.1/32".to_string()], + }; + assert!(ResourcePolicy::from_dto(&dto).is_err()); + } + + #[test] + fn client_keys_separate_policies_and_redirect_modes() { + let first = ResourceClientKey::new(policy(&["a.home.arpa"], &[]).digest(), ResourceRedirectMode::Bounded); + let same = ResourceClientKey::new(policy(&["a.home.arpa"], &[]).digest(), ResourceRedirectMode::Bounded); + let other_mode = + ResourceClientKey::new(policy(&["a.home.arpa"], &[]).digest(), ResourceRedirectMode::NoRedirect); + let other_policy = + ResourceClientKey::new(policy(&["b.home.arpa"], &[]).digest(), ResourceRedirectMode::Bounded); + + assert_eq!(first, same); + assert_ne!(first, other_mode); + assert_ne!(first, other_policy); + } +} diff --git a/backend/core/src/model/config/source.rs b/backend/core/src/model/config/source.rs index 6228c45f0..6a78b68f2 100644 --- a/backend/core/src/model/config/source.rs +++ b/backend/core/src/model/config/source.rs @@ -1,4 +1,4 @@ -use crate::model::{macros, ConfigInput, ConfigTarget, ProcessTargets}; +use crate::model::{macros, ConfigInput, ConfigTarget, ProcessTargets, ResourcePolicy}; use indexmap::IndexMap; use parking_lot::RwLock; use shared::{ @@ -433,6 +433,12 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { .unwrap_or_default(); for input_dto in &dto.inputs { + // Reject an invalid policy instead of falling back to a partially normalized + // allowlist; a typo in a host or CIDR must not silently widen or narrow access. + if let Some(policy_dto) = &input_dto.resource_policy { + ResourcePolicy::from_dto(policy_dto) + .map_err(|err| TuliproxError::ConfigInput(format!("input '{}': {err}", input_dto.name)))?; + } let mut input = ConfigInput::from(input_dto); // Prepare input if let Some(path) = input.prepare(&provider)? { @@ -442,6 +448,8 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { inputs.push(input); } + check_unique_member_names(&inputs)?; + // Resolve staged playlist inputs to their configured download type. // The provider link is kept for overlay routing and must not affect staged playlist fetching. if inputs.iter().any(|input| input.input_type.is_staged()) { @@ -471,6 +479,32 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { } } +/// Rejects duplicate input and alias names. +/// +/// The canonical input name is the identity a resource origin resolves to, and aliases share that +/// namespace through `group_lookup`. A duplicate would silently resolve to whichever entry was +/// inserted last, which would authorize a resource against the wrong policy, so it is a load error. +fn check_unique_member_names(inputs: &[ConfigInput]) -> Result<(), TuliproxError> { + let mut seen: HashMap<&str, &str> = HashMap::with_capacity(inputs.len()); + for input in inputs { + if seen.insert(input.name.as_ref(), "input").is_some() { + return Err(TuliproxError::ConfigSource(format!("input names should be unique: {}", input.name))); + } + } + for input in inputs { + for alias in input.aliases.iter().flatten() { + if let Some(owner) = seen.get(alias.name.as_ref()) { + return Err(TuliproxError::ConfigSource(format!( + "input alias names should be unique: '{}' of input '{}' already names an {owner}", + alias.name, input.name + ))); + } + seen.insert(alias.name.as_ref(), "alias"); + } + } + Ok(()) +} + impl SourcesConfig { pub fn get_source_at(&self, idx: usize) -> Option<&ConfigSource> { self.sources.get(idx) } @@ -576,10 +610,120 @@ impl SourcesConfig { #[cfg(test)] mod tests { - use super::ConfigProvider; - use shared::model::{ConfigProviderDto, ProviderUrlSelectionPolicy}; + use super::{ConfigProvider, SourcesConfig}; + use shared::model::{ + ConfigInputDto, ConfigProviderDto, ProviderUrlSelectionPolicy, ResourcePolicyDto, SourcesConfigDto, + }; use std::net::IpAddr; + fn sources_dto(resource_policy: Option) -> SourcesConfigDto { + SourcesConfigDto { + templates: None, + provider: None, + inputs: vec![ConfigInputDto { + name: "policy-input".into(), + url: "https://provider.example/playlist.m3u".to_string(), + resource_policy, + ..ConfigInputDto::default() + }], + sources: Vec::new(), + } + } + + #[test] + fn a_valid_resource_policy_loads_into_the_runtime_input() { + let dto = sources_dto(Some(ResourcePolicyDto { + allowed_hosts: vec!["Media.Home.Arpa".to_string()], + allowed_networks: vec!["192.168.50.20/32".to_string()], + })); + + let sources = SourcesConfig::try_from(&dto).expect("policy should be accepted"); + let input = sources.inputs.first().expect("input"); + let policy = input.resource_policy.as_ref().expect("policy"); + + assert!(policy.allows_host("media.home.arpa")); + assert_eq!(policy.allowed_networks.len(), 1); + } + + #[test] + fn an_invalid_resource_policy_fails_the_config_load() { + for invalid in [ + ResourcePolicyDto { allowed_hosts: vec!["https://host/path".to_string()], allowed_networks: Vec::new() }, + ResourcePolicyDto { allowed_hosts: Vec::new(), allowed_networks: vec!["0.0.0.0/0".to_string()] }, + ResourcePolicyDto { allowed_hosts: Vec::new(), allowed_networks: vec!["public.example/24".to_string()] }, + ] { + let dto = sources_dto(Some(invalid.clone())); + let error = SourcesConfig::try_from(&dto).expect_err("invalid policy must be rejected"); + assert!(error.to_string().contains("resource_policy"), "error should name the policy: {error}"); + } + } + + #[test] + fn duplicate_input_names_fail_the_config_load() { + let mut dto = sources_dto(None); + dto.inputs.push(ConfigInputDto { + name: "policy-input".into(), + url: "https://other.example/playlist.m3u".to_string(), + ..ConfigInputDto::default() + }); + + let error = SourcesConfig::try_from(&dto).expect_err("duplicate input name must be rejected"); + + assert!(error.to_string().contains("input names should be unique"), "{error}"); + } + + #[test] + fn duplicate_alias_names_fail_the_config_load() { + let mut dto = sources_dto(None); + dto.inputs[0].aliases = Some(vec![shared::model::ConfigInputAliasDto { + name: "shared-alias".into(), + url: "https://provider.example/alias.m3u".to_string(), + ..shared::model::ConfigInputAliasDto::default() + }]); + dto.inputs.push(ConfigInputDto { + name: "second-input".into(), + url: "https://second.example/playlist.m3u".to_string(), + aliases: Some(vec![shared::model::ConfigInputAliasDto { + name: "shared-alias".into(), + url: "https://second.example/alias.m3u".to_string(), + ..shared::model::ConfigInputAliasDto::default() + }]), + ..ConfigInputDto::default() + }); + + let error = SourcesConfig::try_from(&dto).expect_err("duplicate alias name must be rejected"); + + assert!(error.to_string().contains("input alias names should be unique"), "{error}"); + } + + #[test] + fn an_alias_name_colliding_with_an_input_name_fails_the_config_load() { + let mut dto = sources_dto(None); + dto.inputs.push(ConfigInputDto { + name: "second-input".into(), + url: "https://second.example/playlist.m3u".to_string(), + aliases: Some(vec![shared::model::ConfigInputAliasDto { + name: "policy-input".into(), + url: "https://second.example/alias.m3u".to_string(), + ..shared::model::ConfigInputAliasDto::default() + }]), + ..ConfigInputDto::default() + }); + + let error = SourcesConfig::try_from(&dto).expect_err("alias shadowing an input name must be rejected"); + + assert!(error.to_string().contains("already names an input"), "{error}"); + } + + #[test] + fn an_input_without_a_resource_policy_has_none() { + let dto = sources_dto(None); + + let sources = SourcesConfig::try_from(&dto).expect("load"); + + assert!(sources.inputs.first().expect("input").resource_policy.is_none()); + } + #[test] fn hostnames_from_urls_preserve_definition_order() { let provider = ConfigProvider::from(&ConfigProviderDto { diff --git a/backend/core/src/model/provider.rs b/backend/core/src/model/provider.rs index 97c7d6660..40157e322 100644 --- a/backend/core/src/model/provider.rs +++ b/backend/core/src/model/provider.rs @@ -584,6 +584,7 @@ mod tests { provider_configs: None, cache_duration_seconds: 0, stalker: None, + resource_policy: None, }; let conn = Arc::new(RwLock::new(ProviderConfigConnection::default())); let counter = Arc::new(AtomicUsize::new(0)); diff --git a/backend/core/src/model/xmltv.rs b/backend/core/src/model/xmltv.rs index 66c3e0ede..8ac31cfae 100644 --- a/backend/core/src/model/xmltv.rs +++ b/backend/core/src/model/xmltv.rs @@ -97,6 +97,8 @@ pub struct PersistedEpgSource { pub priority: i16, pub logo_override: bool, pub kind: PersistedEpgSourceKind, + /// Canonical input that owns resource values read from this source. + pub input_name: Option>, } fn filter_channels_and_programmes(channels: &mut Vec, programmes: &mut Vec) { diff --git a/backend/core/src/utils/lru_cache.rs b/backend/core/src/utils/lru_cache.rs index e7e356fb7..44efa731f 100644 --- a/backend/core/src/utils/lru_cache.rs +++ b/backend/core/src/utils/lru_cache.rs @@ -12,6 +12,25 @@ const CACHEDIR_TAG: &str = "CACHEDIR.TAG"; #[inline] fn encode_cache_key(key: &str) -> String { encode_base64_hash(key) } +/// Cache identity of a proxied resource. +/// +/// One URL can be fetched under different destination policies, input identities, and credential +/// contexts. All three scopes belong to the key so a body fetched in one authorization context can +/// never answer another. The version domain invalidates entries created with an older layout. +pub fn resource_cache_key( + policy_digest: &str, + canonical_input: &str, + credential_context: &str, + canonical_url: &str, +) -> String { + format!( + "tuliprox.resource.v2|{}:{policy_digest}|{}:{canonical_input}|{}:{credential_context}|{canonical_url}", + policy_digest.len(), + canonical_input.len(), + credential_context.len(), + ) +} + /// `LRUResourceCache` /// /// A least-recently-used (LRU) file-based resource cache that stores files in a directory on disk, @@ -246,3 +265,20 @@ impl LRUResourceCache { } } } + +#[cfg(test)] +mod cache_key_tests { + use super::resource_cache_key; + + #[test] + fn resource_cache_key_separates_policies() { + let url = "https://cdn.example.com/logo.png"; + let public_only = resource_cache_key("public", "input", "credentials", url); + let private = resource_cache_key("private", "input", "credentials", url); + + assert_ne!(public_only, private); + assert_ne!(public_only, resource_cache_key("public", "other-input", "credentials", url)); + assert_ne!(public_only, resource_cache_key("public", "input", "other-credentials", url)); + assert_eq!(public_only, resource_cache_key("public", "input", "credentials", url)); + } +} diff --git a/backend/core/src/utils/mod.rs b/backend/core/src/utils/mod.rs index 8b281ac23..db313c6ce 100644 --- a/backend/core/src/utils/mod.rs +++ b/backend/core/src/utils/mod.rs @@ -17,6 +17,7 @@ mod ordinal; mod provider_resolve_token; mod recording_paths; pub mod request_headers; +mod resource_token; pub mod response_compression; pub mod runtime_liveness; mod step_measure; @@ -83,6 +84,7 @@ pub use self::{ ordinal::*, provider_resolve_token::*, recording_paths::*, + resource_token::*, step_measure::*, sys_utils::*, telegram::*, diff --git a/backend/core/src/utils/network/request.rs b/backend/core/src/utils/network/request.rs index 25340d6b8..ec0a293d8 100644 --- a/backend/core/src/utils/network/request.rs +++ b/backend/core/src/utils/network/request.rs @@ -8,7 +8,8 @@ pub const STREAM_IDLE_TIMEOUT: u64 = 60; use crate::{ model::{ resolve_provider_scheme_url_with_provider_index, AppConfig, Config, ConfigInput, ConfigProvider, InputSource, - ResourceRetryConfig, ReverseProxyDisabledHeaderConfig, + ResourcePolicy, ResourcePolicyError, ResourceRedirectMode, ResourceRetryConfig, + ReverseProxyDisabledHeaderConfig, }, utils::{ async_file_reader, async_file_writer, @@ -51,7 +52,7 @@ use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}, time::sleep, }; -use url::Url; +use url::{Host, Url}; static PROXY_DIAGNOSTICS_ONCE: Once = Once::new(); @@ -70,6 +71,77 @@ impl reqwest::dns::Resolve for PublicIpResolver { } } +/// Resolves a host and keeps only the addresses the policy authorizes. +/// +/// The resolver is the connection-time enforcement point for host names. It runs on every +/// connection, so a DNS answer that changes between requests is re-evaluated instead of being +/// trusted from a one-time pre-check. IP literals in a URL bypass DNS entirely, which is why +/// `ResourcePolicy::validate_initial_url` and the redirect policy classify them separately. +#[derive(Debug, Clone)] +pub struct PolicyIpResolver { + policy: Arc, +} + +impl PolicyIpResolver { + pub fn new(policy: Arc) -> Self { Self { policy } } +} + +impl reqwest::dns::Resolve for PolicyIpResolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let host = name.as_str().to_string(); + let policy = Arc::clone(&self.policy); + Box::pin(async move { + let addresses = resolve_policy_socket_addrs(&host, 0, &policy) + .await + .map_err(|err| Box::new(err) as Box)?; + Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +pub async fn resolve_policy_socket_addrs( + host: &str, + port: u16, + policy: &ResourcePolicy, +) -> std::io::Result> { + let addresses: Vec = if let Ok(address) = host.parse::() { + vec![SocketAddr::new(address, port)] + } else { + tokio::net::lookup_host((host, port)).await?.collect() + }; + + if addresses.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "destination did not resolve to an address")); + } + + // Mixed answers keep only the approved addresses: a blocked entry must never be left in the + // list as a usable fallback, and an approved one must not be dropped because a sibling entry + // was rejected. + let literal = host.parse::().ok(); + let mut approved = Vec::with_capacity(addresses.len()); + let mut rejection = None; + for address in addresses { + // A literal has no host name to match, so it is judged by the address policy alone. IP + // literals normally never reach a resolver; this keeps the decision consistent if one does. + let decision = match literal { + Some(literal) => policy.authorize_literal(literal), + None => policy.authorize_resolved(host, address.ip()), + }; + match decision { + Ok(()) => approved.push(address), + Err(err) => rejection = Some(err), + } + } + + if approved.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + rejection.map_or_else(|| "destination is not authorized".to_string(), |err| err.to_string()), + )); + } + Ok(approved) +} + pub async fn resolve_public_socket_addrs(host: &str, port: u16) -> std::io::Result> { let addresses = if let Ok(address) = host.parse::() { vec![SocketAddr::new(address, port)] @@ -85,17 +157,57 @@ pub async fn resolve_public_socket_addrs(host: &str, port: u16) -> std::io::Resu Ok(addresses) } -pub fn is_public_ip(address: IpAddr) -> bool { - match address { - IpAddr::V4(address) => is_public_ipv4(address), - IpAddr::V6(address) => is_public_ipv6(address), +/// How a destination address is treated when a resource policy decides on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressClass { + /// Routable on the public internet. + Public, + /// RFC 1918 or IPv6 ULA: reachable only through an explicit policy entry. + Private, + /// Never reachable, with or without a policy. + Blocked, +} + +/// Classifies an address, canonicalizing IPv4-mapped IPv6 first so a mapped private address is +/// judged as the IPv4 address it represents. +pub fn classify_ip(address: IpAddr) -> AddressClass { + match canonicalize_ip(address) { + IpAddr::V4(address) => classify_ipv4(address), + IpAddr::V6(address) => classify_ipv6(address), } } -fn is_public_ipv4(address: Ipv4Addr) -> bool { +/// Collapses an IPv4-mapped IPv6 address to the IPv4 address it represents. +/// +/// Policy entries are stored as either family, so both the classification and the network +/// containment check have to see the same family the operator wrote down. +pub fn canonicalize_ip(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(address) => address.to_ipv4_mapped().map_or(IpAddr::V6(address), IpAddr::V4), + IpAddr::V4(address) => IpAddr::V4(address), + } +} + +fn embedded_ipv4(address: Ipv6Addr) -> Option { + let segments = address.segments(); + let (high, low) = if segments[..6] == [0, 0, 0, 0, 0, 0] || segments[..6] == [0x0064, 0xff9b, 0, 0, 0, 0] { + (segments[6], segments[7]) + } else if segments[0] == 0x2002 { + (segments[1], segments[2]) + } else { + return None; + }; + let [a, b] = high.to_be_bytes(); + let [c, d] = low.to_be_bytes(); + Some(Ipv4Addr::new(a, b, c, d)) +} + +fn classify_ipv4(address: Ipv4Addr) -> AddressClass { let [a, b, _, _] = address.octets(); - !(address.is_private() - || address.is_loopback() + if address.is_private() { + return AddressClass::Private; + } + if address.is_loopback() || address.is_link_local() || address.is_broadcast() || address.is_documentation() @@ -104,21 +216,36 @@ fn is_public_ipv4(address: Ipv4Addr) -> bool { || a == 0 || (a == 100 && (64..=127).contains(&b)) || (a == 198 && matches!(b, 18 | 19)) - || a >= 240) + || a >= 240 + { + return AddressClass::Blocked; + } + AddressClass::Public } -fn is_public_ipv6(address: Ipv6Addr) -> bool { +fn classify_ipv6(address: Ipv6Addr) -> AddressClass { + if let Some(address) = embedded_ipv4(address) { + return classify_ipv4(address); + } let segments = address.segments(); - !(address.is_loopback() + if segments[0] & 0xfe00 == 0xfc00 { + // fc00::/7 unique local addresses. + return AddressClass::Private; + } + if address.is_loopback() || address.is_unspecified() || address.is_multicast() - || segments[0] & 0xfe00 == 0xfc00 || segments[0] & 0xffc0 == 0xfe80 || segments[0] & 0xffc0 == 0xfec0 - || (segments[0] == 0x2001 && segments[1] == 0x0db8)) - && address.to_ipv4_mapped().is_none_or(is_public_ipv4) + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + { + return AddressClass::Blocked; + } + AddressClass::Public } +pub fn is_public_ip(address: IpAddr) -> bool { matches!(classify_ip(address), AddressClass::Public) } + /// Options applied at the final boundary of every physical request attempt. #[derive(Debug, Clone, Copy, Default)] pub struct RequestFetchOptions { @@ -2942,6 +3069,84 @@ pub fn create_client(cfg: &AppConfig) -> reqwest::ClientBuilder { create_client_with_redirect(cfg, Policy::limited(10)) } +/// Redirect hops allowed for a resource fetch. The same bound the general client uses. +pub const RESOURCE_REDIRECT_LIMIT: usize = 10; + +/// Redirect policy for resource fetches. +/// +/// reqwest's engine stays in place so method rewriting, `Referer` handling, and stripping of +/// sensitive cross-origin headers keep working. The policy only adds the two checks reqwest +/// cannot do itself: a hop bound and the IP-literal check for redirect targets, which never reach +/// the DNS resolver. +pub fn resource_redirect_policy(policy: Arc, mode: ResourceRedirectMode) -> Policy { + match mode { + ResourceRedirectMode::NoRedirect => Policy::none(), + ResourceRedirectMode::Bounded => Policy::custom(move |attempt| { + if attempt.previous().len() >= RESOURCE_REDIRECT_LIMIT { + return attempt.error(ResourcePolicyError::TooManyRedirects); + } + match attempt.url().host() { + None => attempt.error(ResourcePolicyError::MissingHost), + Some(Host::Ipv4(address)) => match policy.authorize_literal(IpAddr::V4(address)) { + Ok(()) => attempt.follow(), + Err(err) => attempt.error(err), + }, + Some(Host::Ipv6(address)) => match policy.authorize_literal(IpAddr::V6(address)) { + Ok(()) => attempt.follow(), + Err(err) => attempt.error(err), + }, + Some(Host::Domain(_)) => attempt.follow(), + } + }), + } +} + +/// Warns once per client build that resource-proxy fetches ignore the configured proxy. +/// +/// A proxy resolves names on its own side, which would bypass connection-time destination +/// validation, so every policy-checked resource fetch connects directly. This applies to +/// public-only resources as well, which is why it is not tied to a `resource_policy` being set. +pub fn warn_resource_proxy_bypass(config: &Config) { + let proxy_configured = config.proxy.is_some(); + let env_proxy_configured = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); + + if proxy_configured { + warn!( + "A proxy is configured, but server-side resource-proxy fetches connect directly so \ + destination policy can be enforced at connection time" + ); + } + if env_proxy_configured { + warn!( + "HTTP_PROXY/HTTPS_PROXY/ALL_PROXY are set, but server-side resource-proxy fetches \ + ignore environment proxies so destination policy can be enforced at connection time" + ); + } +} + +/// Builds a client for one normalized policy and redirect mode. +/// +/// Resource clients always connect directly: the policy resolver must see the real destination +/// address, and a proxy would resolve it elsewhere. +pub fn create_resource_http_client( + cfg: &AppConfig, + policy: Arc, + mode: ResourceRedirectMode, +) -> Result { + let config = cfg.config.load(); + let redirect_policy = resource_redirect_policy(Arc::clone(&policy), mode); + let mut builder = create_client_with_redirect(cfg, redirect_policy) + .no_proxy() + .dns_resolver(PolicyIpResolver::new(policy)) + .http1_only(); + if config.connect_timeout_secs > 0 { + builder = builder.connect_timeout(Duration::from_secs(u64::from(config.connect_timeout_secs))); + } + builder.build().map_err(|err| TuliproxError::Config(format!("Failed to create resource HTTP client: {err}"))) +} + pub fn parse_range(range: &str) -> Option<(u64, Option)> { // expect: "bytes=START-END" if !range.starts_with("bytes=") { @@ -3000,14 +3205,16 @@ pub fn should_trigger_failover(status: StatusCode) -> bool { #[cfg(test)] mod tests { use super::{ - download_text_content, download_text_content_with_headers_and_options, get_input_epg_content_as_file, - get_remote_content_as_stream, is_safe_cross_origin_redirect_header, next_provider_url_index, - preview_request_diagnostics_for_logging, preview_request_target_for_logging, resolve_attempt_target, - same_origin, send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result, + classify_ip, download_text_content, download_text_content_with_headers_and_options, + get_input_epg_content_as_file, get_remote_content_as_stream, is_safe_cross_origin_redirect_header, + next_provider_url_index, preview_request_diagnostics_for_logging, preview_request_target_for_logging, + resolve_attempt_target, same_origin, + send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result, send_input_with_retry_and_provider_policy_with_options_result, send_with_retry_and_provider, send_with_retry_and_provider_policy, should_retry_text_body_error, should_try_next_ip_on_connect_error, - strip_sensitive_headers_for_cross_origin_redirect, text_response_error_log_label, InputEpgFileRequest, - PublicIpResolver, RequestFetchOptions, TextContentBodyOptions, TextContentFetchOptions, STREAM_IDLE_TIMEOUT, + strip_sensitive_headers_for_cross_origin_redirect, text_response_error_log_label, AddressClass, + InputEpgFileRequest, PublicIpResolver, RequestFetchOptions, TextContentBodyOptions, TextContentFetchOptions, + STREAM_IDLE_TIMEOUT, }; use crate::{ model::{ @@ -3036,7 +3243,7 @@ mod tests { use std::{ collections::{HashMap, HashSet}, io::{Error, ErrorKind, Write}, - net::SocketAddr, + net::{IpAddr, SocketAddr}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -3051,6 +3258,32 @@ mod tests { }; use url::Url; + #[test] + fn embedded_ipv4_destinations_use_ipv4_classification() { + for (address, expected) in [ + ("64:ff9b::a00:1", AddressClass::Private), + ("64:ff9b::808:808", AddressClass::Public), + ("2002:a00:1::", AddressClass::Private), + ("2002:808:808::", AddressClass::Public), + ("::a00:1", AddressClass::Private), + ("::808:808", AddressClass::Public), + ("64:ff9b::7f00:1", AddressClass::Blocked), + ] { + assert_eq!(classify_ip(address.parse::().expect("valid IP address")), expected, "{address}"); + } + } + + #[test] + fn native_ipv6_destinations_keep_ipv6_classification() { + for (address, expected) in [ + ("2001:4860:4860::8888", AddressClass::Public), + ("fc00::1", AddressClass::Private), + ("2001:db8::1", AddressClass::Blocked), + ] { + assert_eq!(classify_ip(address.parse::().expect("valid IP address")), expected, "{address}"); + } + } + fn make_test_app_config(config: Config) -> Arc { Arc::new(AppConfig { config: Arc::new(ArcSwap::from_pointee(config)), diff --git a/backend/core/src/utils/resource_token.rs b/backend/core/src/utils/resource_token.rs new file mode 100644 index 000000000..8d5ef72d8 --- /dev/null +++ b/backend/core/src/utils/resource_token.rs @@ -0,0 +1,135 @@ +//! Encoding and decoding of authenticated resource links. +//! +//! Two legacy encodings predate this module and are neither confidential nor authenticated: +//! `/resource/epg/...` uses `obscure_text`, the Web UI resource route uses `obfuscate_text`. +//! Both carry a bare URL, so a token cannot be traced back to the input that supplied the URL and +//! every legacy link is treated as public-only. New links use the payload defined here, which +//! carries the origin and is protected by the existing authenticated helper. + +use crate::utils::crypto_utils::{deobscure_authenticated_bytes, obscure_authenticated_bytes}; +use shared::model::ResourceToken; +use std::fmt; + +/// Prefix that discriminates a new token from a legacy encoding. It marks the token type, not a +/// payload version: the payload version stays in the authenticated envelope. +pub const RESOURCE_TOKEN_PREFIX: &str = "a1_"; + +const RESOURCE_TOKEN_DOMAIN: &[u8] = b"tuliprox.resource-token.v1"; + +/// Largest accepted token. Checked against the raw string before base64 decoding, so an oversized +/// token is rejected before it allocates or deserializes anything. +pub const MAX_RESOURCE_TOKEN_BYTES: usize = 16384; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceTokenError { + TooLarge, + MissingPrefix, + Invalid, +} + +impl fmt::Display for ResourceTokenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooLarge => f.write_str("resource token exceeds the size limit"), + Self::MissingPrefix => f.write_str("resource token has no token prefix"), + Self::Invalid => f.write_str("resource token is malformed or not authenticated"), + } + } +} + +impl std::error::Error for ResourceTokenError {} + +/// True when the encoded value is a new authenticated token rather than a legacy encoding. +pub fn has_resource_token_prefix(encoded: &str) -> bool { encoded.starts_with(RESOURCE_TOKEN_PREFIX) } + +/// Encodes a token, or reports that the payload does not fit the accepted size. +pub fn encode_resource_token(secret: &[u8; 16], token: &ResourceToken) -> Result { + let payload = rmp_serde::to_vec(token).map_err(|_| ResourceTokenError::Invalid)?; + let encoded = obscure_authenticated_bytes(secret, RESOURCE_TOKEN_DOMAIN, &payload) + .map_err(|_| ResourceTokenError::Invalid)?; + if encoded.len() + RESOURCE_TOKEN_PREFIX.len() > MAX_RESOURCE_TOKEN_BYTES { + return Err(ResourceTokenError::TooLarge); + } + Ok(format!("{RESOURCE_TOKEN_PREFIX}{encoded}")) +} + +pub fn decode_resource_token(secret: &[u8; 16], encoded: &str) -> Result { + if encoded.len() > MAX_RESOURCE_TOKEN_BYTES { + return Err(ResourceTokenError::TooLarge); + } + let payload = encoded.strip_prefix(RESOURCE_TOKEN_PREFIX).ok_or(ResourceTokenError::MissingPrefix)?; + if payload.is_empty() { + return Err(ResourceTokenError::Invalid); + } + let bytes = deobscure_authenticated_bytes(secret, RESOURCE_TOKEN_DOMAIN, payload) + .map_err(|_| ResourceTokenError::Invalid)?; + rmp_serde::from_slice(&bytes).map_err(|_| ResourceTokenError::Invalid) +} + +pub fn resource_token(resource: &str) -> ResourceToken { ResourceToken { resource: resource.to_string() } } + +#[cfg(test)] +mod tests { + use super::{ + decode_resource_token, encode_resource_token, has_resource_token_prefix, resource_token, ResourceTokenError, + MAX_RESOURCE_TOKEN_BYTES, RESOURCE_TOKEN_PREFIX, + }; + use rand::Rng; + + fn random_secret() -> [u8; 16] { rand::rng().random() } + + #[test] + fn token_round_trip_keeps_url_and_origin() { + let secret = random_secret(); + let token = resource_token("resource://v1/example"); + let encoded = encode_resource_token(&secret, &token).expect("encode"); + + assert!(has_resource_token_prefix(&encoded)); + assert_eq!(decode_resource_token(&secret, &encoded).expect("decode"), token); + } + + #[test] + fn raw_public_resource_round_trips() { + let secret = random_secret(); + let token = resource_token("https://cdn.example.com/logo.png"); + let encoded = encode_resource_token(&secret, &token).expect("encode"); + let decoded = decode_resource_token(&secret, &encoded).expect("decode"); + + assert_eq!(decoded.resource, "https://cdn.example.com/logo.png"); + } + + #[test] + fn tampered_or_foreign_tokens_are_rejected() { + let secret = random_secret(); + let token = resource_token("https://cdn.example.com/logo.png"); + let encoded = encode_resource_token(&secret, &token).expect("encode"); + + let mut tampered = encoded.clone(); + let last = tampered.pop().expect("token char"); + tampered.push(if last == 'A' { 'B' } else { 'A' }); + assert_eq!(decode_resource_token(&secret, &tampered), Err(ResourceTokenError::Invalid)); + + // A different secret must not authenticate the same payload. + assert_eq!(decode_resource_token(&random_secret(), &encoded), Err(ResourceTokenError::Invalid)); + + // A legacy encoding has no prefix and is never decoded as a token. + let legacy = shared::utils::obfuscate_text(&secret, "https://cdn.example.com/logo.png"); + assert_eq!(decode_resource_token(&secret, &legacy), Err(ResourceTokenError::MissingPrefix)); + } + + #[test] + fn oversized_tokens_are_rejected_before_decoding() { + let secret = random_secret(); + let oversized = format!("{RESOURCE_TOKEN_PREFIX}{}", "A".repeat(MAX_RESOURCE_TOKEN_BYTES)); + assert_eq!(decode_resource_token(&secret, &oversized), Err(ResourceTokenError::TooLarge)); + + let long_url = format!("https://cdn.example.com/{}", "a".repeat(MAX_RESOURCE_TOKEN_BYTES)); + assert_eq!(encode_resource_token(&secret, &resource_token(&long_url)), Err(ResourceTokenError::TooLarge)); + } + + #[test] + fn empty_payload_is_rejected() { + let secret = random_secret(); + assert_eq!(decode_resource_token(&secret, RESOURCE_TOKEN_PREFIX), Err(ResourceTokenError::Invalid)); + } +} diff --git a/backend/metadata/src/manager.rs b/backend/metadata/src/manager.rs index 8251055da..978e19a08 100644 --- a/backend/metadata/src/manager.rs +++ b/backend/metadata/src/manager.rs @@ -2589,7 +2589,36 @@ impl InputWorker { false } - // Changed to static method + fn ingest_vod_resources(updates: &mut [(ProviderIdType, VideoStreamProperties)], input_name: &Arc) { + for (_, props) in updates { + let mut value = StreamProperties::Video(Box::new(std::mem::take(props))); + value.normalize_internal_resource_values(input_name); + if let StreamProperties::Video(normalized) = value { + *props = *normalized; + } + } + } + + fn ingest_series_resources(updates: &mut [(ProviderIdType, SeriesStreamProperties)], input_name: &Arc) { + for (_, props) in updates { + let mut value = StreamProperties::Series(Box::new(std::mem::take(props))); + value.normalize_internal_resource_values(input_name); + if let StreamProperties::Series(normalized) = value { + *props = *normalized; + } + } + } + + fn ingest_live_resources(updates: &mut [(ProviderIdType, LiveStreamProperties)], input_name: &Arc) { + for (_, props) in updates { + let mut value = StreamProperties::Live(Box::new(std::mem::take(props))); + value.normalize_internal_resource_values(input_name); + if let StreamProperties::Live(normalized) = value { + *props = *normalized; + } + } + } + async fn flush_batch_static( input_name: &str, bound_ctx: Option<&MetadataUpdateCtx>, @@ -2602,9 +2631,13 @@ impl InputWorker { let Some(ctx) = bound_ctx else { return }; let app_config = &ctx.app_config; let cfg = app_config.config.load(); - let vod_updates = batch_buffer.take_vod_updates(); - let series_updates = batch_buffer.take_series_updates(); - let live_updates = batch_buffer.take_live_updates(); + let mut vod_updates = batch_buffer.take_vod_updates(); + let mut series_updates = batch_buffer.take_series_updates(); + let mut live_updates = batch_buffer.take_live_updates(); + let input_name_arc: Arc = Arc::from(input_name); + Self::ingest_vod_resources(&mut vod_updates, &input_name_arc); + Self::ingest_series_resources(&mut series_updates, &input_name_arc); + Self::ingest_live_resources(&mut live_updates, &input_name_arc); if vod_updates.is_empty() && series_updates.is_empty() && live_updates.is_empty() { return; diff --git a/backend/parser/src/m3u.rs b/backend/parser/src/m3u.rs index b26203075..98542be0d 100644 --- a/backend/parser/src/m3u.rs +++ b/backend/parser/src/m3u.rs @@ -471,6 +471,7 @@ fn process_header_internal( } } + plih.ingest_resource_values(input_name); plih.freeze_input_stream_id(); plih } @@ -793,7 +794,7 @@ mod test { use crate::m3u::{classify_token, parse_m3u, process_header, M3uToken}; use shared::{ defaults::default_episode_pattern, - model::{PlaylistItemType, StreamProperties, XtreamCluster, REGEX_CACHE}, + model::{PlaylistItemType, ResourceLocator, StreamProperties, XtreamCluster, REGEX_CACHE}, utils::{fnv1a_32, parse_season_episode, Internable, CONSTANTS}, }; use tokio::io::AsyncWriteExt; @@ -822,6 +823,12 @@ mod test { } } + fn assert_resource(locator: &str, input_name: &str, url: &str) { + let locator = ResourceLocator::decode(locator).expect("resource locator"); + assert_eq!(locator.input_name.as_ref(), input_name); + assert_eq!(locator.url.as_ref(), url); + } + #[tokio::test] async fn m3u_update_quality_parse_failure_does_not_yield_a_partial_candidate() { for text in [ @@ -1089,7 +1096,7 @@ https://example.test/series/user/pass/episode-2 // tvg-id is preserved as epg_channel_id, id falls back to numeric url segment assert_eq!(pli.epg_channel_id, Some("abc-seven".intern())); assert_eq!(pli.id, "70001".intern()); - assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern()); + assert_resource(&pli.logo, "hello", "https://abc.nz/.images/seven.png"); assert_eq!(pli.chno, 7); assert_eq!(&*pli.group, "Sydney"); } @@ -1105,7 +1112,7 @@ https://example.test/series/user/pass/episode-2 assert_eq!(pli.title, "Seven".intern()); assert_eq!(pli.epg_channel_id, Some("abc-seven".intern())); assert_eq!(pli.id, "70002".intern()); - assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern()); + assert_resource(&pli.logo, "hello", "https://abc.nz/.images/seven.png"); assert_eq!(pli.chno, 7); assert_eq!(&*pli.group, "Sydney"); } @@ -1121,7 +1128,7 @@ https://example.test/series/user/pass/episode-2 assert_eq!(pli.name, "UK-NOWTV| SKY CRIME FHD".intern()); assert_eq!(pli.title, "UK-NOWTV| SKY CRIME FHD".intern()); assert_eq!(pli.id, "1905905".intern()); // URL id is master; CUID is only fallback - assert_eq!(pli.logo, "https://logo.m3uassets.com/skycrime.png".intern()); + assert_resource(&pli.logo, "test", "https://logo.m3uassets.com/skycrime.png"); assert_eq!(&*pli.group, "🔪Murder Mystery"); assert_eq!(pli.epg_channel_id, Some("skycrime.uk".intern())); } diff --git a/backend/parser/src/xtream.rs b/backend/parser/src/xtream.rs index dcc480674..65a053e2c 100644 --- a/backend/parser/src/xtream.rs +++ b/backend/parser/src/xtream.rs @@ -153,6 +153,7 @@ pub fn parse_xtream_series_info( ..Default::default() }, }; + item.header.ingest_resource_values(&input.name); item.header.freeze_input_stream_id(); item }) @@ -280,6 +281,7 @@ pub async fn parse_xtream( ..Default::default() }, }; + item.header.ingest_resource_values(&input_name); item.header.freeze_input_stream_id(); group.add(item); } @@ -510,6 +512,7 @@ where ..Default::default() }, }; + item.header.ingest_resource_values(input_name); item.header.freeze_input_stream_id(); // if let Some(StreamProperties::Series(props)) = item.header.additional_properties.as_mut() { diff --git a/backend/processing/src/epg.rs b/backend/processing/src/epg.rs index f2493082b..d4cf06606 100644 --- a/backend/processing/src/epg.rs +++ b/backend/processing/src/epg.rs @@ -9,6 +9,7 @@ use shared::{ use std::{ collections::HashSet, path::{Path, PathBuf}, + sync::Arc, }; use tuliprox_core::{ model::{ConfigInput, EpgSource, EpgSourceType, PersistedEpgSource, PersistedEpgSourceKind}, @@ -200,7 +201,7 @@ pub async fn get_xmltv( match download_epg_file(epg_source, ctx, input, headers, storage_dir).await { Ok(file_path) => { stored_file_paths.push(file_path.clone()); - match persisted_source_from_config(epg_source, file_path) { + match persisted_source_from_config(epg_source, file_path, input) { Ok(persisted) => file_paths.push(persisted), Err(err) => errors.push(err), } @@ -229,6 +230,7 @@ pub async fn get_xmltv( fn persisted_source_from_config( epg_source: &EpgSource, file_path: PathBuf, + input: &ConfigInput, ) -> Result { let kind = match epg_source.source_type { EpgSourceType::Xmltv => PersistedEpgSourceKind::Xmltv, @@ -246,7 +248,13 @@ fn persisted_source_from_config( } }; - Ok(PersistedEpgSource { file_path, priority: epg_source.priority, logo_override: epg_source.logo_override, kind }) + Ok(PersistedEpgSource { + file_path, + priority: epg_source.priority, + logo_override: epg_source.logo_override, + kind, + input_name: Some(Arc::clone(&input.name)), + }) } #[cfg(test)] diff --git a/backend/processing/src/parser/xmltv.rs b/backend/processing/src/parser/xmltv.rs index eb0abe10e..1d98b638d 100644 --- a/backend/processing/src/parser/xmltv.rs +++ b/backend/processing/src/parser/xmltv.rs @@ -4,7 +4,7 @@ use quick_xml::events::{BytesStart, BytesText, Event}; use serde::{Deserialize, Serialize}; use shared::{ concat_string, - model::{EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme}, + model::{has_resource_scheme, ingest_resource_value, EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme}, utils::{deunicode_string, Internable, CONSTANTS}, }; use std::{ @@ -358,6 +358,7 @@ impl TVGuide { if add_channel { with_folded_epg_id(&tag_epg_id, |folded| source_processed.insert(folded.intern())); id_cache.insert_processed_epg_id(&tag_epg_id); + let icon_source = ingest_epg_icon(epg_source, Self::channel_icon(&tag)); accumulator.upsert_channel( epg_source.priority, source_order, @@ -365,7 +366,7 @@ impl TVGuide { EpgChannel { id: Arc::clone(&tag_epg_id), title: Self::channel_display_name(&tag), - icon: Self::channel_icon(&tag), + icon: icon_source, programmes: vec![], }, ); @@ -375,13 +376,14 @@ impl TVGuide { EPG_TAG_PROGRAMME => { if let Some(epg_id) = tag.get_attribute_value(&epg_attrib_channel) { if with_folded_epg_id(epg_id, |folded| source_processed.contains(folded)) { - if let Some(programme) = Self::extract_programme( + if let Some(mut programme) = Self::extract_programme( &tag, epg_id, &start_attrib, &stop_attrib, &catchup_id_attrib, ) { + programme.icon = ingest_epg_icon(epg_source, programme.icon); accumulator.push_programme(epg_source.priority, source_order, programme); } } @@ -445,8 +447,9 @@ impl TVGuide { ) .await { - Ok(channel) => { + Ok(mut channel) => { id_cache.insert_processed_epg_id(channel_id); + channel.icon = ingest_epg_icon(epg_source, channel.icon); accumulator.add_channel_with_programmes( epg_source.priority, source_order, @@ -1131,6 +1134,22 @@ fn apply_dummy_policies(channels: &mut [ChannelMergeAcc], dummy_policies: &HashM } } +/// Origin of an icon belonging to `source`. +/// +/// An icon without a value has no origin, and a source without an origin keeps `None`, which the +/// request path treats as public-only. +fn ingest_epg_icon(source: &PersistedEpgSource, mut icon: Option>) -> Option> { + let value = icon.as_mut()?; + if let Some(input_name) = &source.input_name { + if ingest_resource_value(value, input_name).is_err() { + return None; + } + } else if has_resource_scheme(value) { + return None; + } + icon +} + fn backfill_programme_metadata(existing: &mut EpgProgramme, incoming: EpgProgramme) { if existing.title.is_none() { existing.title = incoming.title; @@ -1308,7 +1327,7 @@ mod tests { } fn xmltv_source(file_path: PathBuf, priority: i16, logo_override: bool) -> PersistedEpgSource { - PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv } + PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv, input_name: None } } fn dummy_policy_source(priority: i16, source_order: usize, title: &str) -> EpgDummyPolicySource { @@ -1915,6 +1934,7 @@ mod tests { ..IcsEpgSourceConfig::default() }), }, + input_name: None, }, ]); let mut id_cache = EpgIdCache::new(None); diff --git a/backend/processing/src/processor/epg.rs b/backend/processing/src/processor/epg.rs index a4dc7b658..ad74f5a22 100644 --- a/backend/processing/src/processor/epg.rs +++ b/backend/processing/src/processor/epg.rs @@ -952,6 +952,7 @@ mod tests { match_names, config: Box::new(IcsEpgSourceConfig::default()), }, + input_name: None, }]) } @@ -1040,6 +1041,7 @@ mod tests { priority: 0, logo_override: false, kind: PersistedEpgSourceKind::Xmltv, + input_name: None, }]); let mut playlist = FetchedPlaylist { input: &input, @@ -1254,6 +1256,7 @@ mod tests { priority: 0, logo_override: true, kind: PersistedEpgSourceKind::Xmltv, + input_name: Some("epg-input".into()), }]); let mut playlist = FetchedPlaylist { input: &input, @@ -1266,8 +1269,12 @@ mod tests { let updated = playlist.items_mut().next().unwrap(); assert_eq!(updated.header.epg_channel_id.as_deref(), Some("demo.channel")); - assert_eq!(updated.header.logo.as_ref(), "http://guide/icon.png"); - assert_eq!(updated.header.logo_small.as_ref(), "http://guide/icon.png"); + let logo = shared::model::ResourceLocator::decode(&updated.header.logo).expect("EPG logo locator"); + let logo_small = + shared::model::ResourceLocator::decode(&updated.header.logo_small).expect("EPG small logo locator"); + assert_eq!(logo.input_name.as_ref(), "epg-input"); + assert_eq!(logo.url.as_ref(), "http://guide/icon.png"); + assert_eq!(logo_small, logo); assert_eq!(epg[0].children[0].id.as_ref(), "Demo.Channel"); }); } @@ -1327,6 +1334,7 @@ mod tests { priority: 0, logo_override: false, kind: PersistedEpgSourceKind::Xmltv, + input_name: None, }]); let mut playlist = FetchedPlaylist { input: &input, @@ -1434,6 +1442,7 @@ mod tests { priority: 0, logo_override: false, kind: PersistedEpgSourceKind::Xmltv, + input_name: None, }]); let mut playlist = FetchedPlaylist { input: &input, @@ -1468,6 +1477,7 @@ mod tests { priority: 0, logo_override: false, kind: PersistedEpgSourceKind::Xmltv, + input_name: None, }]); let mut playlist = FetchedPlaylist { input: &input, diff --git a/backend/processing/src/processor/library.rs b/backend/processing/src/processor/library.rs index bae2c44ef..c757b40dd 100644 --- a/backend/processing/src/processor/library.rs +++ b/backend/processing/src/processor/library.rs @@ -139,14 +139,21 @@ fn to_playlist_item( ) { match &entry.metadata { MediaMetadata::Movie(_) => { - channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path)); + let mut item = build_movie_playlist_item(entry, input_name, group_name, api_base_path); + item.header.ingest_resource_values(input_name); + channels.push(item); } MediaMetadata::Series(_) => { if let Some((series_info, episodes)) = build_series_playlist_items(entry, input_name, group_name, api_base_path) { + let mut series_info = series_info; + series_info.header.ingest_resource_values(input_name); channels.push(series_info); - channels.extend(episodes); + channels.extend(episodes.into_iter().map(|mut episode| { + episode.header.ingest_resource_values(input_name); + episode + })); } } } diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index d07566649..f2473f453 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -1563,6 +1563,7 @@ mod mapping_stage { match_names: vec!["BBC One".intern()], config: Box::new(IcsEpgSourceConfig::default()), }, + input_name: None, }]); let mut playlist = FetchedPlaylist { @@ -1658,6 +1659,7 @@ match { match_names: vec![], config: Box::new(IcsEpgSourceConfig::default()), }, + input_name: None, }]); let mut playlist = FetchedPlaylist { input: &input, diff --git a/backend/processing/src/processor/playlist/transform.rs b/backend/processing/src/processor/playlist/transform.rs index a0f6b5f5a..b2232b8b1 100644 --- a/backend/processing/src/processor/playlist/transform.rs +++ b/backend/processing/src/processor/playlist/transform.rs @@ -164,6 +164,12 @@ impl MappingStageOutcome { } pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome { + let mut trusted_locators = HashSet::new(); + channel.header.visit_resource_values(&mut |value| { + if shared::model::resolve_resource_value(value).is_ok_and(|locator| locator.is_some()) { + trusted_locators.insert(Arc::clone(value)); + } + }); let mut matched_rules = 0; let mut virtual_items = vec![]; let mut changed_fields = HashSet::new(); @@ -198,6 +204,16 @@ pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) } } } + let normalize_mapped_item = |item: &mut PlaylistItem| { + item.header.visit_resource_values_mut(&mut |value| { + if shared::model::has_resource_scheme(value) && !trusted_locators.contains(value) { + *value = Arc::from(""); + } + }); + item.header.normalize_internal_resource_values(); + }; + normalize_mapped_item(&mut channel); + virtual_items.iter_mut().for_each(normalize_mapped_item); ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics } } diff --git a/backend/processing/src/processor/stalker.rs b/backend/processing/src/processor/stalker.rs index c61e95439..c84a829f9 100644 --- a/backend/processing/src/processor/stalker.rs +++ b/backend/processing/src/processor/stalker.rs @@ -202,7 +202,7 @@ pub async fn download_stalker_playlist( app_config, api_client.as_ref(), &handshake, - refresh_plan, + refresh_plan.clone(), &storage_path, identity_fingerprint, refresh_mode.budget(), diff --git a/backend/processing/src/processor/stalker_refresh.rs b/backend/processing/src/processor/stalker_refresh.rs index 5009cc3f3..e54515f88 100644 --- a/backend/processing/src/processor/stalker_refresh.rs +++ b/backend/processing/src/processor/stalker_refresh.rs @@ -128,11 +128,12 @@ impl StalkerClusterSelection { } /// Requested clusters and the publication policy applied once their generation is complete. -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct StalkerRefreshPlan { selection: StalkerClusterSelection, update_quality: ConfigInputUpdateQuality, quality_bypass_mask: u8, + input_name: Arc, } impl StalkerRefreshPlan { @@ -143,7 +144,7 @@ impl StalkerRefreshPlan { let quality_bypass_mask = matches!(quality_policy, UpdateQualityPolicy::Bypass) .then_some(selection.mask() & MEDIA_SELECTION) .unwrap_or(0); - Self { selection, update_quality, quality_bypass_mask } + Self { selection, update_quality, quality_bypass_mask, input_name: Arc::clone(&input.name) } } } @@ -559,13 +560,16 @@ fn map_items( categories: &HashMap, kind: StalkerStreamKind, added_at: i64, + input_name: &Arc, ) -> Vec { raw_items .iter() .map(|raw| { let category = raw.category_id().and_then(|value| value.parse::().ok()).and_then(|id| categories.get(&id)); - parser::map_stalker_to_playlist_item(raw, category, kind, added_at) + let mut item = parser::map_stalker_to_playlist_item(raw, category, kind, added_at); + item.ingest_resource_values(input_name); + item }) .collect() } @@ -636,7 +640,7 @@ pub async fn advance_stalker_refresh( identity_fingerprint: u64, mut budget: StalkerRefreshBudget, ) -> Result { - let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask } = refresh_plan; + let StalkerRefreshPlan { selection, update_quality, quality_bypass_mask, input_name } = refresh_plan; let mut checkpoint = load_or_start_checkpoint(storage_path, identity_fingerprint, selection, quality_bypass_mask).await?; if checkpoint.phase == StalkerRefreshPhase::Terminal { @@ -673,7 +677,7 @@ pub async fn advance_stalker_refresh( checkpoint.retry_count = 0; } Ok(raw) => { - let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at); + let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at, &input_name); let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live); snapshot_stalker_items_at(app_config, path.clone(), &items).await?; @@ -707,7 +711,7 @@ pub async fn advance_stalker_refresh( { return yield_after_error(storage_path, checkpoint, err).await; } - let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at); + let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at, &input_name); let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live); upsert_stalker_items_at(app_config, &path, &items).await?; checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64); @@ -739,7 +743,7 @@ pub async fn advance_stalker_refresh( { return yield_after_error(storage_path, checkpoint, err).await; } - let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at); + let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at, &input_name); let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Vod); upsert_stalker_items_at(app_config, &path, &items).await?; checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64); @@ -786,6 +790,7 @@ pub async fn advance_stalker_refresh( root.flussonic_tmp_link = capabilities.flussonic_temporary_link; root.wowza_tmp_link = capabilities.wowza_temporary_link; root.use_http_tmp_link = capabilities.use_http_temporary_link; + root.ingest_resource_values(&input_name); root }) .collect(); @@ -830,7 +835,10 @@ pub async fn advance_stalker_refresh( used_episode_ids .insert(prepare_stalker_episode_series_at(app_config, &path, series_id).await?) }; - let episodes = parser::map_stalker_series_details(&details, &root, added_at, used); + let mut episodes = parser::map_stalker_series_details(&details, &root, added_at, used); + for episode in &mut episodes { + episode.ingest_resource_values(&input_name); + } upsert_stalker_items_at(app_config, &path, &episodes).await?; checkpoint.processed = checkpoint.processed.saturating_add(episodes.len() as u64); checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) }; @@ -1521,7 +1529,7 @@ mod tests { StalkerCategory { id: "10".to_string(), title: "News".to_string(), alias: None, number: 1 }, )]); - let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0); + let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0, &Arc::from("stalker-input")); assert_eq!(items.len(), 1); assert_eq!(items[0].category_id, 10); diff --git a/backend/repository/src/xtream_repository.rs b/backend/repository/src/xtream_repository.rs index 576849b8b..491b22d1e 100644 --- a/backend/repository/src/xtream_repository.rs +++ b/backend/repository/src/xtream_repository.rs @@ -2612,8 +2612,9 @@ async fn persist_input_info( cluster: XtreamCluster, input_name: &str, provider_id: u32, - props: StreamProperties, + mut props: StreamProperties, ) -> Result<(), Error> { + props.normalize_internal_resource_values(&Arc::from(input_name)); let xtream_path = xtream_get_file_path(storage_path, cluster); if xtream_path.exists() { let file_lock = app_config.file_locks.write_lock(&xtream_path).await; @@ -2656,11 +2657,15 @@ pub async fn persist_input_info_batch( storage_path: &Path, cluster: XtreamCluster, input_name: &str, - updates: Vec<(u32, StreamProperties)>, + mut updates: Vec<(u32, StreamProperties)>, ) -> Result<(), Error> { if updates.is_empty() { return Ok(()); } + let input_name_arc: Arc = Arc::from(input_name); + for (_, props) in &mut updates { + props.normalize_internal_resource_values(&input_name_arc); + } let xtream_path = xtream_get_file_path(storage_path, cluster); if xtream_path.exists() { let file_lock = app_config.file_locks.write_lock(&xtream_path).await; diff --git a/backend/session/src/provider_lineup_manager.rs b/backend/session/src/provider_lineup_manager.rs index 73ef5d4a5..5d2cb9a5e 100644 --- a/backend/session/src/provider_lineup_manager.rs +++ b/backend/session/src/provider_lineup_manager.rs @@ -1122,6 +1122,7 @@ mod tests { provider_configs: None, cache_duration_seconds: 0, stalker: None, + resource_policy: None, } } diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 2042d4d13..cf019e025 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -144,6 +144,7 @@ inputs: | `aliases` | List | No | | Connection pooling / Sub-accounts (see [below](#input-subsections-object-keys)). | | `staged` | Object | No | | Staged overlay settings. Only valid when `type: staged` (see [below](#input-subsections-object-keys)). | | `panel_api` | Object | No | | Automated reseller account generation (see [below](#input-subsections-object-keys)). | +| `resource_policy` | Object | No | | Trusted private destinations for resource URLs supplied by this input (see [below](#27-resource-policy-resource_policy)). | #### Minimal Stalker Input Example @@ -207,14 +208,15 @@ logic. ### Input Subsections (Object Keys) -| Block | Description | Link | -|:------------|:---------------------------------------------------------------------------|:---------------------------------------------------| -| `headers` | Custom HTTP request headers for playlist and EPG downloads. | [See Headers](#21-headers-headers) | -| `options` | Behavior controls for metadata resolution, stream probing, and skip logic. | [See Options](#22-input-options-options) | -| `epg` | XMLTV source management and Smart Match fuzzy logic settings. | [See EPG](#23-epg-assignment--smart-match-epg) | -| `aliases` | Connection pooling for multiple subscriptions from the same provider. | [See Aliases](#24-provider-aliases-aliases--batch) | -| `staged` | Overlay settings for first-class staged inputs. | [See Staged](#25-staged-sources-staged) | -| `panel_api` | Automated reseller panel integration (provisioning/renewal). | [See Panel API](#26-provider-panel-api-panel_api) | +| Block | Description | Link | +| :---------------- | :------------------------------------------------------------------------- | :--------------------------------------------------------- | +| `headers` | Custom HTTP request headers for playlist and EPG downloads. | [See Headers](#21-headers-headers) | +| `options` | Behavior controls for metadata resolution, stream probing, and skip logic. | [See Options](#22-input-options-options) | +| `epg` | XMLTV source management and Smart Match fuzzy logic settings. | [See EPG](#23-epg-assignment--smart-match-epg) | +| `aliases` | Connection pooling for multiple subscriptions from the same provider. | [See Aliases](#24-provider-aliases-aliases--batch) | +| `staged` | Overlay settings for first-class staged inputs. | [See Staged](#25-staged-sources-staged) | +| `panel_api` | Automated reseller panel integration (provisioning/renewal). | [See Panel API](#26-provider-panel-api-panel_api) | +| `resource_policy` | Trusted private destinations for resource URLs supplied by this input. | [See Resource Policy](#27-resource-policy-resource_policy) | --- @@ -1151,6 +1153,104 @@ Tuliprox processes all Panel API responses as JSON and strictly requires `status --- +### 2.7 Resource Policy (`resource_policy`) + +Restricts which destinations Tuliprox may reach when it proxies a resource URL that came from this input, including +channel logos, EPG channel and programme icons, covers, posters, episode images, backdrops, and nested Xtream +metadata resources. + +Without a policy, every resource URL is treated as **public-only**: only publicly routable destinations are +fetched. An internal logo host therefore stops working until it is listed here. + +```yaml +inputs: + - name: local-playlist + type: m3u + url: /data/local-playlist.m3u + resource_policy: + allowed_hosts: + - media.home.arpa + allowed_networks: + - 192.168.50.20/32 +``` + +#### Configure it in the Web UI + +1. Open the **Source Editor** and select the input that supplies the resource. +2. Open the **Resource Policy** page using the shield icon. +3. Add the exact DNS names under **Allowed Resource Hosts**. Enter host names only, without a scheme, path, port, + wildcard, or IP address. +4. Add the smallest required private CIDR ranges under **Allowed Resource Networks**. +5. Apply the input changes and save the source configuration. + +For a private DNS destination, configure both a matching host and network. A private IP literal needs only a +matching network. Removing every host and network removes the policy from the input and restores the public-only +default. + +#### Parameters + +| Parameter | Type | Required | Default | Technical Impact & Background | +| :--------------------- | :--- | :------: | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`allowed_hosts`** | List | No | `[]` | Exact DNS names that may resolve to a private address. Matched case-insensitively and without a trailing dot. Wildcards, ports, schemes, paths, and IP literals are rejected while the configuration is loaded. | +| **`allowed_networks`** | List | No | `[]` | Private CIDR ranges a destination address may fall into (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` and subnets of them). Anything broader, and every public or special-use range, is rejected while the configuration is loaded. Use `/32`, `/128`, or the smallest practical subnet. | + +#### What a policy authorizes + +* A **publicly routable** destination is always allowed, with or without a policy. +* A **private DNS destination** requires both: the exact host name in `allowed_hosts` **and** the resolved address + inside `allowed_networks`. Listing one without the other authorizes nothing. +* A **private IP literal** in the URL (for example `http://192.168.1.1/logo.png`) is authorized by + `allowed_networks` alone, because a literal has no host name to match. +* Loopback, link-local, cloud-metadata, unspecified, multicast, CGNAT, documentation, benchmarking, broadcast, and + reserved addresses are always rejected, even when a matching CIDR is configured. +* Redirects are re-checked on every hop with the same policy, and redirect hops are bounded. +* `allowed_hosts` and `allowed_networks` contain no port, so an authorized host is reachable on **every** port that + serves `http` or `https`. This is deliberate: providers serve images on non-standard ports, and the address + policy is the actual restriction, not the port. + +#### Where the policy comes from + +The policy is looked up on the input that supplied the concrete resource URL, not on the target, the item, or the +record that contains it. Two consequences matter in practice: + +* Items delivered through an alias are authorized with the policy of their main input; aliases inherit it. +* `logo_override: true` copies an EPG icon into a playlist logo. That logo keeps the EPG input as its origin and is + authorized with the **EPG input's** policy, not the playlist input's. + +The canonical input name is the authorization identity of a resource origin. Configured input and alias names are +non-empty, globally unique strings: no two inputs — and no input and alias — may share a name, and the loader rejects +a configuration that does. Internal numeric IDs and generated playlist UUIDs are managed separately from these names. +Renaming an input invalidates the links that were issued for it, and reusing a name for a different input would hand +it the authority of the old input, so treat a rename as a new identity. + +#### Legacy data and links + +Legacy raw resources stored on playlist and Xtream items use the containing item's input. Legacy EPG resources and +external links without an authoritative input remain public-only until the data is regenerated. An origin that +names an input which no longer exists or is disabled is rejected with `400`; it is never silently downgraded to +public-only. + +Tuliprox stores ownership internally in a value beginning with `resource://`. This scheme is reserved and must not +appear in provider data, playlists, EPG documents, metadata, or mapping configuration. Provider-supplied and mapped +values using the reserved scheme are discarded so they cannot claim another input's network policy. External M3U, +XMLTV, Xtream, and Web UI responses always contain either the original HTTP(S) URL or a Tuliprox proxy URL, never the +internal representation. + +#### Proxy interaction + +Resource proxying always connects directly, so the destination policy can be enforced while the connection is +established. A configured proxy, and the `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` environment variables, are +ignored for these requests; Tuliprox logs a warning at startup and on reload when one is configured. Provider +fetches, playlist and EPG downloads, and streams keep using the proxy. + +#### Cache + +Proxied resources are cached per policy scope. An entry fetched under one policy is never served to a request +authorized by another, and the resource cache starts cold once on upgrade because the cache key layout changes. +The cache size limits are unchanged. + +--- + ## 3. Routing & Targets (`sources`) This block links your inputs to one or more output targets and defines how Tuliprox transforms, filters, sorts, and diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 613c95b09..a87528388 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -493,6 +493,8 @@ }, "INPUT_FORM": { "ALIASES": "أسماء مستعارة اختيارية تُستخدم لإدارة خطوط متعددة من نفس المزود.", + "RESOURCE_POLICY_ALLOWED_HOSTS": "أسماء DNS الدقيقة المسموح لها بالتحويل إلى عنوان خاص. أدخل اسم المضيف فقط دون مخطط أو مسار أو منفذ أو حرف بدل أو عنوان IP. يتطلب مورد DNS الخاص تطابق المضيف والشبكة معًا.", + "RESOURCE_POLICY_ALLOWED_NETWORKS": "نطاقات CIDR الخاصة المسموح بها لموارد هذا الإدخال. تُقبل فقط الشبكات الفرعية من 10.0.0.0/8 و172.16.0.0/12 و192.168.0.0/16 وfc00::/7. استخدم أصغر شبكة فرعية عملية.", "EPG_SOURCES": "مصادر EPG (دليل البرامج الإلكتروني) المرتبطة بهذا الإدخال.\n يمكنك استخدام `auto` لإنشاء عنوان URL لـ EPG بناءً على إعدادات الإدخال الخاصة بك،\nأو يمكنك كتابة عنوان URL الكامل لـ EPG.", "FETCH_METHOD": "طريقة HTTP المستخدمة لاسترداد الإدخال (مثل GET، POST).", "HEADERS": "رؤوس HTTP المخصصة المُرسلة عند استرداد قائمة تشغيل الإدخال هذه.", @@ -914,6 +916,7 @@ "ADD_FORMAT": "إضافة تنسيق", "ADD_HEADER": "إضافة رأس", "ADD_COUNTRY": "إضافة بلد", + "ADD_HOST": "إضافة مضيف", "ADD_NETWORK": "إضافة شبكة", "ADD_MAPPING": "إضافة تعيين", "ADD_PATTERN": "إضافة نمط", @@ -946,6 +949,7 @@ "ADD_VALUE": "إضافة قيمة", "AUTOMATIC_CODEC_TRIGGER_POLICY": "سياسة التشغيل التلقائي للترميز", "ALLOWED_COUNTRIES": "البلدان المسموح بها", + "ALLOWED_HOSTS": "المضيفون المسموح بهم", "ALLOWED_NETWORKS": "الشبكات المسموح بها", "ACCOUNT_TOKEN": "رمز الحساب", "ALLOW_RELAY": "السماح بالترحيل", @@ -1443,6 +1447,9 @@ "RESOLVE_FILTER": "مرشح الحل", "RESOLVE_TMDB": "حل TMDB", "RESOURCE_IMAGE_CACHE": "ذاكرة التخزين المؤقت للموارد/الصور", + "RESOURCE_ALLOWED_HOSTS": "مضيفو الموارد المسموح بهم", + "RESOURCE_ALLOWED_NETWORKS": "شبكات الموارد المسموح بها", + "RESOURCE_POLICY": "سياسة الموارد", "RESOURCE_RETRY": "إعادة محاولة المورد", "RESOURCE_REWRITE_DISABLE": "تعطيل إعادة كتابة المورد", "RESOURCE_REWRITE_DISABLED": "إعادة كتابة المورد معطلة", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 68d3ee7cc..403626c8a 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -497,6 +497,8 @@ }, "INPUT_FORM": { "ALIASES": "Optional aliases used to manage multiple lines from the same provider.", + "RESOURCE_POLICY_ALLOWED_HOSTS": "Exact DNS names allowed to resolve to a private address. Enter host names only, without scheme, path, port, wildcard, or IP address. A private DNS resource requires both a matching host and network.", + "RESOURCE_POLICY_ALLOWED_NETWORKS": "Private CIDR ranges allowed for resources from this input. Only subnets of 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and fc00::/7 are accepted. Use the smallest practical subnet.", "EPG_SOURCES": "Associated EPG (Electronic Program Guide) sources for this input.\n You can use `auto` to create the epg url based on your input settings,\nor you can write the full epg url.", "FETCH_METHOD": "HTTP method used to retrieve the input (e.g., GET, POST).", "HEADERS": "Custom HTTP headers sent when retrieving the input playlist.", @@ -916,6 +918,7 @@ "ADD_FORMAT": "Add Format", "ADD_HEADER": "Add header", "ADD_COUNTRY": "Add country", + "ADD_HOST": "Add host", "ADD_NETWORK": "Add network", "ADD_MAPPING": "Add Mapping", "ADD_PATTERN": "Add Pattern", @@ -949,6 +952,7 @@ "ADD_VALUE": "Add value", "AUTOMATIC_CODEC_TRIGGER_POLICY": "automatic codec trigger policy", "ALLOWED_COUNTRIES": "Allowed Countries", + "ALLOWED_HOSTS": "Allowed Hosts", "ALLOWED_NETWORKS": "Allowed Networks", "ACCOUNT_TOKEN": "Account token", "ALLOW_RELAY": "Allow relay", @@ -1528,6 +1532,9 @@ "RESOLVE_FILTER": "Resolve Filter", "RESOLVE_TMDB": "Resolve TMDB", "RESOURCE_IMAGE_CACHE": "Resource/Image Cache", + "RESOURCE_ALLOWED_HOSTS": "Allowed Resource Hosts", + "RESOURCE_ALLOWED_NETWORKS": "Allowed Resource Networks", + "RESOURCE_POLICY": "Resource Policy", "RESOURCE_RETRY": "Resource Retry", "RESOURCE_REWRITE_DISABLE": "Resource Rewrite disable", "RESOURCE_REWRITE_DISABLED": "Resource Rewrite Disabled", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index ce646ba60..16f00e41f 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -469,6 +469,8 @@ }, "INPUT_FORM": { "ALIASES": "Необязательные псевдонимы, используемые для управления несколькими линиями от одного провайдера.", + "RESOURCE_POLICY_ALLOWED_HOSTS": "Точные DNS-имена, которым разрешено разрешаться в частный адрес. Указывайте только имя узла без схемы, пути, порта, шаблона или IP-адреса. Для частного DNS-ресурса должны совпадать и узел, и сеть.", + "RESOURCE_POLICY_ALLOWED_NETWORKS": "Частные CIDR-сети, разрешённые для ресурсов этого входа. Допустимы только подсети 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и fc00::/7. Используйте минимально необходимую подсеть.", "EPG_SOURCES": "Связанные источники EPG (Electronic Program Guide) для этого ввода.\n Вы можете использовать `auto` для создания URL epg на основе ваших настроек ввода,\nили вы можете написать полный URL epg.", "FETCH_METHOD": "HTTP-метод, используемый для получения ввода (например, GET, POST).", "HEADERS": "Пользовательские HTTP-заголовки, отправляемые при получении плейлиста ввода.", @@ -877,6 +879,7 @@ "ADD_FORMAT": "Добавить формат", "ADD_HEADER": "Добавить заголовок", "ADD_COUNTRY": "Добавить страну", + "ADD_HOST": "Добавить узел", "ADD_NETWORK": "Добавить сеть", "ADD_MAPPING": "Добавить сопоставление", "ADD_PATTERN": "Добавить шаблон", @@ -908,6 +911,7 @@ "ADD_VALUE": "Добавить значение", "AUTOMATIC_CODEC_TRIGGER_POLICY": "политика автоматического запуска кодека", "ALLOWED_COUNTRIES": "Разрешенные страны", + "ALLOWED_HOSTS": "Разрешённые узлы", "ALLOWED_NETWORKS": "Разрешенные сети", "API": "API", "API_CONFIG": "API", @@ -1383,6 +1387,9 @@ "RESOLVE_DELAY_SEC": "Задержка разрешения (сек)", "RESOLVE_FILTER": "Фильтр разрешения", "RESOLVE_TMDB": "Разрешать TMDB", + "RESOURCE_ALLOWED_HOSTS": "Разрешённые узлы ресурсов", + "RESOURCE_ALLOWED_NETWORKS": "Разрешённые сети ресурсов", + "RESOURCE_POLICY": "Политика ресурсов", "RESOURCE_RETRY": "Повтор ресурса", "RESOURCE_REWRITE_DISABLE": "Отключить переписывание ресурсов", "RESOURCE_REWRITE_DISABLED": "Переписывание ресурсов отключено", diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 9ae4e3e8c..05fc316d6 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -22,7 +22,8 @@ use crate::{ app::{ components::{ config::HasFormData, AliasItemForm, BlockId, BlockInstance, Card, EditMode, EpgSmartMatchForm, - EpgSourceItemForm, IconButton, Panel, ProviderItemForm, SourceEditorContext, TextButton, + EpgSourceItemForm, IconButton, Panel, ProviderItemForm, SourceEditorContext, Tag, TagList, TextButton, + TitledCard, }, ConfigContext, }, @@ -36,7 +37,7 @@ use shared::{ ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, ConfigInputUpdateQualityDto, ConfigProviderDto, EpgSmartMatchConfigDto, EpgSourceDto, InputFetchMethod, InputType, MediaServerInputConfigDto, MediaServerLibrarySelector, OnConnectErrorPolicy, ProviderUrlSelectionPolicy, - StagedInputType, StalkerDeviceProfileDto, StalkerInputConfigDto, + ResourcePolicyDto, StagedInputType, StalkerDeviceProfileDto, StalkerInputConfigDto, }, utils::{Internable, BATCH_SCHEME_PREFIX}, }; @@ -120,6 +121,11 @@ const LABEL_SERVER_ID: &str = "LABEL.SERVER_ID"; const LABEL_SERVER_NAME: &str = "LABEL.SERVER_NAME"; const LABEL_PREFER_HTTPS: &str = "LABEL.PREFER_HTTPS"; const LABEL_ALLOW_RELAY: &str = "LABEL.ALLOW_RELAY"; +const LABEL_RESOURCE_POLICY: &str = "LABEL.RESOURCE_POLICY"; +const LABEL_RESOURCE_ALLOWED_HOSTS: &str = "LABEL.RESOURCE_ALLOWED_HOSTS"; +const LABEL_RESOURCE_ALLOWED_NETWORKS: &str = "LABEL.RESOURCE_ALLOWED_NETWORKS"; +const LABEL_ADD_HOST: &str = "LABEL.ADD_HOST"; +const LABEL_ADD_NETWORK: &str = "LABEL.ADD_NETWORK"; fn input_persist_hint_key(staged_input: bool) -> &'static str { if staged_input { @@ -241,6 +247,7 @@ const LABEL_EDIT_EPG_SMART_MATCH: &str = "LABEL.EDIT_EPG_SMART_MATCH"; #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum InputFormPage { Main, + ResourcePolicy, Device, Options, Libraries, @@ -251,6 +258,7 @@ enum InputFormPage { impl InputFormPage { const MAIN: &str = "Main"; + const RESOURCE_POLICY: &str = "ResourcePolicy"; const DEVICE: &str = "Device"; const OPTIONS: &str = "Options"; const LIBRARIES: &str = "Libraries"; @@ -265,6 +273,7 @@ impl FromStr for InputFormPage { fn from_str(s: &str) -> Result { match s { Self::MAIN => Ok(InputFormPage::Main), + Self::RESOURCE_POLICY => Ok(InputFormPage::ResourcePolicy), Self::DEVICE => Ok(InputFormPage::Device), Self::OPTIONS => Ok(InputFormPage::Options), Self::LIBRARIES => Ok(InputFormPage::Libraries), @@ -283,6 +292,7 @@ impl Display for InputFormPage { "{}", match *self { InputFormPage::Main => Self::MAIN, + InputFormPage::ResourcePolicy => Self::RESOURCE_POLICY, InputFormPage::Device => Self::DEVICE, InputFormPage::Options => Self::OPTIONS, InputFormPage::Libraries => Self::LIBRARIES, @@ -298,6 +308,7 @@ impl Internable for InputFormPage { fn intern(self) -> Arc { match self { Self::Main => Self::MAIN, + Self::ResourcePolicy => Self::RESOURCE_POLICY, Self::Device => Self::DEVICE, Self::Options => Self::OPTIONS, Self::Libraries => Self::LIBRARIES, @@ -326,9 +337,28 @@ fn input_form_pages(input_type: shared::model::InputType) -> Vec if !input_type.is_library() && !input_type.is_staged() && !input_type.is_media_server() { pages.extend([InputFormPage::Epg, InputFormPage::Provider]); } + pages.push(InputFormPage::ResourcePolicy); pages } +fn normalized_resource_policy(mut policy: ResourcePolicyDto) -> Option { + fn normalize(values: &mut Vec) { + values.iter_mut().for_each(|value| *value = value.trim().to_string()); + values.retain(|value| !value.is_empty()); + values.dedup(); + } + + normalize(&mut policy.allowed_hosts); + normalize(&mut policy.allowed_networks); + (!policy.is_empty()).then_some(policy) +} + +fn resource_policy_tags(values: &[String]) -> Vec> { + values.iter().map(|value| Rc::new(Tag { label: value.clone(), class: None })).collect() +} + +fn tag_values(tags: Vec>) -> Vec { tags.into_iter().map(|tag| tag.label.clone()).collect() } + fn normalize_optional_device_field(value: &mut Option) { *value = value.take().and_then(|value| { let value = value.trim(); @@ -402,6 +432,7 @@ generate_form_reducer!( MediaServer => media_server: Option, ExpDate => exp_date: Option, CacheDuration => cache_duration: Option, + ResourcePolicy => resource_policy: Option, } ); @@ -896,6 +927,55 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; + let render_resource_policy = || { + let policy = input_form_state.form.resource_policy.clone().unwrap_or_default(); + let allowed_hosts = resource_policy_tags(&policy.allowed_hosts); + let allowed_networks = resource_policy_tags(&policy.allowed_networks); + let handle_hosts_change = { + let state = input_form_state.clone(); + Callback::from(move |tags: Vec>| { + let mut policy = state.form.resource_policy.clone().unwrap_or_default(); + policy.allowed_hosts = tag_values(tags); + state.dispatch(ConfigInputFormAction::ResourcePolicy(normalized_resource_policy(policy))); + }) + }; + let handle_networks_change = { + let state = input_form_state.clone(); + Callback::from(move |tags: Vec>| { + let mut policy = state.form.resource_policy.clone().unwrap_or_default(); + policy.allowed_networks = tag_values(tags); + state.dispatch(ConfigInputFormAction::ResourcePolicy(normalized_resource_policy(policy))); + }) + }; + + html! { + + + { config_field_child!(translate.t(LABEL_RESOURCE_ALLOWED_HOSTS), "INPUT_FORM.RESOURCE_POLICY_ALLOWED_HOSTS", { + html! { + + } + })} + { config_field_child!(translate.t(LABEL_RESOURCE_ALLOWED_NETWORKS), "INPUT_FORM.RESOURCE_POLICY_ALLOWED_NETWORKS", { + html! { + + } + })} + + + } + }; + let render_alias = || { let aliases = aliases_state.clone(); let show_alias_form = show_alias_form_state.clone(); @@ -1251,6 +1331,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { Callback::from(move |_| { let mut input = input_form_state.data().clone(); + input.resource_policy = input.resource_policy.take().and_then(normalized_resource_policy); let options = input_options_state.data(); input.options = if options.is_empty() { None } else { Some(options.clone()) }; @@ -1332,6 +1413,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { {render_main()} + + {render_resource_policy()} + { html_if!(stalker_input, { @@ -1396,6 +1480,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { })} + } }; @@ -1434,6 +1519,32 @@ mod tests { assert_eq!(InputFormPage::Libraries.to_string(), InputFormPage::LIBRARIES); } + #[test] + fn resource_policy_page_is_available_for_every_input_family() { + assert_eq!(InputFormPage::from_str(InputFormPage::RESOURCE_POLICY).ok(), Some(InputFormPage::ResourcePolicy)); + assert_eq!(InputFormPage::ResourcePolicy.to_string(), InputFormPage::RESOURCE_POLICY); + for input_type in [InputType::M3u, InputType::Xtream, InputType::Stalker, InputType::Library, InputType::Plex] { + assert!(input_form_pages(input_type).contains(&InputFormPage::ResourcePolicy)); + } + } + + #[test] + fn resource_policy_materialization_trims_values_and_omits_empty_policy() { + let policy = ResourcePolicyDto { + allowed_hosts: vec![" media.home.arpa ".to_string(), String::new()], + allowed_networks: vec![" 192.168.50.0/24 ".to_string()], + }; + + assert_eq!( + normalized_resource_policy(policy), + Some(ResourcePolicyDto { + allowed_hosts: vec!["media.home.arpa".to_string()], + allowed_networks: vec!["192.168.50.0/24".to_string()], + }) + ); + assert_eq!(normalized_resource_policy(ResourcePolicyDto::default()), None); + } + #[test] fn stalker_device_page_is_directly_after_main() { assert_eq!(InputFormPage::from_str(InputFormPage::DEVICE).ok(), Some(InputFormPage::Device)); @@ -1548,6 +1659,37 @@ mod tests { Ok(()) } + #[test] + fn resource_policy_translations_exist_in_every_frontend_locale() -> Result<(), serde_json::Error> { + let locales = [ + ("en", include_str!("../../../../public/assets/i18n/en.json")), + ("ru", include_str!("../../../../public/assets/i18n/ru.json")), + ("ar", include_str!("../../../../public/assets/i18n/ar.json")), + ]; + + for (locale, source) in locales { + let translations: serde_json::Value = serde_json::from_str(source)?; + for pointer in [ + "/LABEL/RESOURCE_POLICY", + "/LABEL/RESOURCE_ALLOWED_HOSTS", + "/LABEL/RESOURCE_ALLOWED_NETWORKS", + "/LABEL/ADD_HOST", + "/LABEL/ADD_NETWORK", + "/EXPLANATION/INPUT_FORM/RESOURCE_POLICY_ALLOWED_HOSTS", + "/EXPLANATION/INPUT_FORM/RESOURCE_POLICY_ALLOWED_NETWORKS", + ] { + assert!( + translations + .pointer(pointer) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()), + "missing {pointer} translation for {locale}" + ); + } + } + Ok(()) + } + #[test] fn libraries_from_text_preserves_existing_detailed_selector() { let detailed = MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto { diff --git a/shared/Cargo.toml b/shared/Cargo.toml index d3f54eca6..422c2aaab 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -12,6 +12,7 @@ bitflags = "2.13.1" ciborium = "0.2.2" hex = "0.4.3" serde_tuple = "1.1.3" +rmp-serde = "1.3.1" serde.workspace = true regex.workspace = true log.workspace = true @@ -42,3 +43,5 @@ js-sys = "0.3.104" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.4.3" + +[dev-dependencies] diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 1eb3ae0c9..9cc6a3eac 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -1,4 +1,4 @@ -use super::PanelApiConfigDto; +use super::{PanelApiConfigDto, ResourcePolicyDto}; use crate::{ check_input_connections, check_input_credentials, defaults::{ @@ -524,6 +524,10 @@ pub struct ConfigInputDto { /// is `Stalker` or `StalkerBatch`. #[serde(default, skip_serializing_if = "Option::is_none")] pub stalker: Option, + /// Trusted private destinations for resource URLs supplied by this input. Absent means + /// public destinations only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_policy: Option, } impl Default for ConfigInputDto { @@ -554,6 +558,7 @@ impl Default for ConfigInputDto { panel_api: None, provider: None, stalker: None, + resource_policy: None, } } } diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index 2df4d2f95..241e746f5 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -31,6 +31,7 @@ mod proxy; mod proxy_type; mod rate_limit; mod rename; +mod resource_policy; mod reverse_proxy; mod schedule; mod sort; @@ -93,6 +94,7 @@ pub use proxy_user_status::*; pub use qos_aggregation::*; pub use rate_limit::*; pub use rename::*; +pub use resource_policy::*; pub use reverse_proxy::*; pub use schedule::*; pub use sort::*; diff --git a/shared/src/model/config/resource_policy.rs b/shared/src/model/config/resource_policy.rs new file mode 100644 index 000000000..8fd2c25fd --- /dev/null +++ b/shared/src/model/config/resource_policy.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Trusted private destinations for the resource URLs supplied by one input. +/// +/// `allowed_hosts` holds exact DNS names, `allowed_networks` holds private CIDR ranges. Both +/// empty (or an absent policy) means public destinations only, which is the default for every +/// input. A private target is authorized only when the host name *and* the resolved address are +/// covered, so enabling one private logo host cannot open a whole subnet. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourcePolicyDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_hosts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_networks: Vec, +} + +impl ResourcePolicyDto { + pub fn is_empty(&self) -> bool { self.allowed_hosts.is_empty() && self.allowed_networks.is_empty() } +} diff --git a/shared/src/model/epg.rs b/shared/src/model/epg.rs index f5f79f4d2..8f1ef7c5b 100644 --- a/shared/src/model/epg.rs +++ b/shared/src/model/epg.rs @@ -275,3 +275,75 @@ pub struct EpgProgrammeDto { pub stop: String, pub title: String, } + +/// Append-only compatibility of the persisted EPG records. +/// +/// The B+Tree encodes these structs positionally, so the origin fields are appended at the end of +/// the field list and an EPG database written by an earlier version must still load. +#[cfg(test)] +mod persistence_compatibility { + use super::{EpgCategory, EpgChannel, EpgProgramme}; + use crate::utils::Internable; + use serde::Serialize; + use std::sync::Arc; + + /// The `EpgChannel` field list without the appended origin field. + #[derive(Serialize)] + struct LegacyChannel { + id: Arc, + title: Option>, + icon: Option>, + programmes: Vec, + } + + /// The `EpgProgramme` field list without the appended origin field. + #[derive(Serialize)] + struct LegacyProgramme { + start: i64, + stop: i64, + title: Option>, + desc: Option>, + catchup_id: Option>, + categories: Vec, + is_live: bool, + is_new: bool, + previously_shown: bool, + icon: Option>, + } + + #[test] + fn channel_written_before_origin_tracking_still_decodes() { + let legacy = LegacyChannel { + id: "epg.channel".intern(), + title: Some("Channel".intern()), + icon: Some("https://cdn.example.com/icon.png".intern()), + programmes: Vec::new(), + }; + let bytes = rmp_serde::to_vec(&legacy).expect("encode legacy channel"); + + let decoded: EpgChannel = rmp_serde::from_slice(&bytes).expect("legacy channel still decodes"); + + assert_eq!(decoded.icon.as_deref(), Some("https://cdn.example.com/icon.png")); + } + + #[test] + fn programme_written_before_origin_tracking_still_decodes() { + let legacy = LegacyProgramme { + start: 100, + stop: 200, + title: Some("Title".intern()), + desc: None, + catchup_id: None, + categories: Vec::new(), + is_live: false, + is_new: false, + previously_shown: false, + icon: Some("https://cdn.example.com/programme.png".intern()), + }; + let bytes = rmp_serde::to_vec(&legacy).expect("encode legacy programme"); + + let decoded: EpgProgramme = rmp_serde::from_slice(&bytes).expect("legacy programme still decodes"); + + assert_eq!(decoded.icon.as_deref(), Some("https://cdn.example.com/programme.png")); + } +} diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index 3f8bdbe73..fb8339d43 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -44,6 +44,7 @@ pub mod recording_catalog; pub mod recording_math; pub mod recording_rule; mod regex_cache; +mod resource; mod scheduled_task_failure; mod search_fields; mod search_request; @@ -82,8 +83,8 @@ pub use self::{ notification_dead_letter::*, pagination::*, playlist::*, playlist_categories::*, playlist_groups_changed::*, playlist_info_document::*, playlist_request::*, playlist_update_run::*, playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*, provider_pool::*, recording::*, recording_math::*, - regex_cache::*, scheduled_task_failure::*, search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, - stalker::*, stalker_item::*, stats::*, status_check::*, stream_history::*, stream_history_record::*, + regex_cache::*, resource::*, scheduled_task_failure::*, search_fields::*, search_request::*, server_lifecycle::*, + short_epg::*, stalker::*, stalker_item::*, stats::*, status_check::*, stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*, stream_probe_failure::*, stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*, target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_lifecycle::*, uuidtype::*, watch_health::*, web_socket::*, xtream::*, diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 5abc350e7..d094f6f2b 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -435,6 +435,51 @@ impl Default for PlaylistItemHeader { } impl PlaylistItemHeader { + pub fn visit_resource_values(&self, visitor: &mut impl FnMut(&Arc)) { + visitor(&self.logo); + visitor(&self.logo_small); + if let Some(properties) = &self.additional_properties { + properties.visit_resource_values(visitor); + } + } + + pub fn visit_resource_values_mut(&mut self, visitor: &mut impl FnMut(&mut Arc)) { + visitor(&mut self.logo); + visitor(&mut self.logo_small); + if let Some(properties) = &mut self.additional_properties { + properties.visit_resource_values_mut(visitor); + } + } + + /// Validates and wraps every resource carried by this item at provider ingress. + pub fn ingest_resource_values(&mut self, input_name: &Arc) -> usize { + let mut rejected = 0; + for value in [&mut self.logo, &mut self.logo_small] { + if crate::model::ingest_resource_value(value, input_name).is_err() { + rejected += 1; + } + } + if let Some(properties) = &mut self.additional_properties { + rejected += properties.ingest_resource_values(input_name); + } + rejected + } + + pub fn normalize_internal_resource_values(&mut self) -> usize { + let input_name = Arc::clone(&self.input_name); + let mut rejected = 0; + for value in [&mut self.logo, &mut self.logo_small] { + if crate::model::normalize_internal_resource_value(value, &input_name).is_err() { + *value = Arc::from(""); + rejected += 1; + } + } + if let Some(properties) = &mut self.additional_properties { + rejected += properties.normalize_internal_resource_values(&input_name); + } + rejected + } + /// Captures the input playlist item ID at the input-processing boundary. /// /// This must run before target transformations and must not be used to recover an identity @@ -492,7 +537,10 @@ macro_rules! to_m3u_non_empty_fields { ($header:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => { $( if !$header.$prop.is_empty() { - let _ = write!($line," {}=\"{}\"", $field, $header.$prop ); + let value = crate::model::external_resource_value(&$header.$prop); + if !value.is_empty() { + let _ = write!($line," {}=\"{}\"", $field, value); + } } )* }; @@ -574,7 +622,12 @@ impl crate::model::FieldGetAccessor for crate::model::PlaylistItemHeader { #[inline] fn get_field(&self, field: &str) -> Option> { use crate::model::FieldGet; - self.get(HeaderField::parse(field)?).map(|value| value.to_arc()) + let field = HeaderField::parse(field)?; + match field { + HeaderField::Logo => Some(crate::model::external_resource_value(&self.logo)), + HeaderField::LogoSmall => Some(crate::model::external_resource_value(&self.logo_small)), + _ => self.get(field).map(|value| value.to_arc()), + } } } @@ -968,14 +1021,15 @@ impl XtreamMappingOptions { return rewrite_url; } + let external_resource = crate::model::external_resource_value(resource_url); let rewrite_url = self.build_reverse_proxy_base_url(xtream_cluster, item_type, virtual_id); if let Some(url) = rewrite_url { - if resource_url.starts_with("http://") || resource_url.starts_with("https://") { + if external_resource.starts_with("http://") || external_resource.starts_with("https://") { return format!("{url}/{resource_field}"); } } - resource_url.to_string() + external_resource.to_string() } pub fn get_bd_path_resource_url( &self, @@ -1001,14 +1055,15 @@ impl XtreamMappingOptions { return rewrite_url; } + let external_resource = crate::model::external_resource_value(resource_url); let rewrite_url = self.build_reverse_proxy_base_url(xtream_cluster, item_type, virtual_id); if let Some(url) = rewrite_url { - if resource_url.starts_with("http://") || resource_url.starts_with("https://") { + if external_resource.starts_with("http://") || external_resource.starts_with("https://") { return format!("{url}/{resource_field}{}_{index}", xtream_const::XC_PROP_BACKDROP_PATH); } } - resource_url.to_string() + external_resource.to_string() } } @@ -1841,6 +1896,29 @@ mod tests { ); } + #[test] + fn get_resource_url_never_exposes_internal_locator_to_xtream_clients() { + let mut options = sample_options(); + options.web_ui_request = false; + options.reverse_item_types = PlaylistItemTypeSet::empty(); + options.resource_proxy_item_types = PlaylistItemTypeSet::empty(); + let locator = + crate::model::ResourceLocator::new(Arc::from("input"), Arc::from("https://provider.example/logo.png")) + .and_then(|locator| locator.encode()) + .expect("locator"); + + assert_eq!( + options.get_resource_url( + XtreamCluster::Live, + PlaylistItemType::Live, + VirtualId::new(2017), + &locator, + "logo", + ), + "https://provider.example/logo.png", + ); + } + #[test] fn get_resource_url_does_not_bypass_untrusted_root_relative_paths_for_web_ui_requests() { let options = sample_options(); @@ -2295,3 +2373,192 @@ mod tests { assert!(!item.to_m3u(None, false).contains("#EXTVLCOPT:http-user-agent=")); } } + +/// Append-only compatibility of the persisted playlist records. +/// +/// The B+Tree stores these structs with `rmp_serde::to_vec` in its compact positional form, so a +/// field added anywhere but the end would shift the meaning of every following byte. These tests +/// encode the field list as it was before origin tracking and decode it with the current struct. +#[cfg(test)] +mod persistence_compatibility { + use super::{ + M3uPlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, VirtualId, XtreamCluster, + XtreamPlaylistItem, + }; + use crate::utils::Internable; + use serde::Serialize; + use std::sync::Arc; + + /// The `M3uPlaylistItem` field list without the appended origin fields. + #[derive(Serialize)] + struct LegacyM3u { + virtual_id: VirtualId, + provider_id: Arc, + name: Arc, + chno: u32, + logo: Arc, + logo_small: Arc, + group: Arc, + title: Arc, + parent_code: Arc, + audio_track: Arc, + time_shift: Arc, + rec: Arc, + url: Arc, + epg_channel_id: Option>, + input_name: Arc, + item_type: PlaylistItemType, + source_ordinal: u32, + additional_properties: Option, + input_stream_id: Arc, + upstream_user_agent: Option>, + } + + /// The `XtreamPlaylistItem` field list without the appended origin fields. + #[derive(Serialize)] + struct LegacyXtream { + virtual_id: VirtualId, + provider_id: u32, + name: Arc, + logo: Arc, + logo_small: Arc, + group: Arc, + title: Arc, + parent_code: Arc, + rec: Arc, + url: Arc, + epg_channel_id: Option>, + xtream_cluster: XtreamCluster, + additional_properties: Option, + item_type: PlaylistItemType, + category_id: u32, + input_name: Arc, + channel_no: u32, + source_ordinal: u32, + input_stream_id: Arc, + upstream_user_agent: Option>, + } + + #[test] + fn m3u_record_written_before_origin_tracking_still_decodes() { + let legacy = LegacyM3u { + virtual_id: VirtualId::new(7), + provider_id: "1".intern(), + name: "Channel".intern(), + chno: 1, + logo: "https://cdn.example.com/logo.png".intern(), + logo_small: "https://cdn.example.com/small.png".intern(), + group: "Group".intern(), + title: "".intern(), + parent_code: "".intern(), + audio_track: "".intern(), + time_shift: "".intern(), + rec: "".intern(), + url: "http://provider.example/stream/1".intern(), + epg_channel_id: Some("channel".intern()), + input_name: "provider".intern(), + item_type: PlaylistItemType::Live, + source_ordinal: 0, + additional_properties: None, + input_stream_id: "1".intern(), + upstream_user_agent: None, + }; + let bytes = rmp_serde::to_vec(&legacy).expect("encode legacy record"); + + let decoded: M3uPlaylistItem = rmp_serde::from_slice(&bytes).expect("legacy record still decodes"); + + assert_eq!(decoded.name, "Channel".intern()); + assert_eq!(decoded.input_name, "provider".intern()); + } + + #[test] + fn xtream_record_written_before_origin_tracking_still_decodes() { + let legacy = LegacyXtream { + virtual_id: VirtualId::new(9), + provider_id: 2, + name: "Movie".intern(), + logo: "https://cdn.example.com/movie.png".intern(), + logo_small: "".intern(), + group: "Movies".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "http://provider.example/movie/2".intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Video, + additional_properties: None, + item_type: PlaylistItemType::Video, + category_id: 4, + input_name: "provider".intern(), + channel_no: 0, + source_ordinal: 0, + input_stream_id: "2".intern(), + upstream_user_agent: Some("Agent/1".intern()), + }; + let bytes = rmp_serde::to_vec(&legacy).expect("encode legacy record"); + + let decoded: XtreamPlaylistItem = rmp_serde::from_slice(&bytes).expect("legacy record still decodes"); + + assert_eq!(decoded.provider_id, 2); + assert_eq!(decoded.upstream_user_agent.as_deref(), Some("Agent/1")); + } + + #[test] + fn header_written_before_origin_tracking_still_decodes() { + #[derive(Serialize)] + struct LegacyHeader { + id: Arc, + name: Arc, + logo: Arc, + logo_small: Arc, + group: Arc, + title: Arc, + parent_code: Arc, + audio_track: Arc, + time_shift: Arc, + rec: Arc, + url: Arc, + epg_channel_id: Option>, + input_name: Arc, + additional_properties: Option, + virtual_id: VirtualId, + chno: u32, + category_id: u32, + source_ordinal: u32, + xtream_cluster: XtreamCluster, + item_type: PlaylistItemType, + input_stream_id: Arc, + upstream_user_agent: Option>, + } + + let legacy = LegacyHeader { + id: "1".intern(), + name: "Channel".intern(), + logo: "https://cdn.example.com/logo.png".intern(), + logo_small: "".intern(), + group: "Group".intern(), + title: "".intern(), + parent_code: "".intern(), + audio_track: "".intern(), + time_shift: "".intern(), + rec: "".intern(), + url: "http://provider.example/stream/1".intern(), + epg_channel_id: None, + input_name: "provider".intern(), + additional_properties: None, + virtual_id: VirtualId::new(1), + chno: 0, + category_id: 0, + source_ordinal: 0, + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + input_stream_id: "1".intern(), + upstream_user_agent: None, + }; + let bytes = rmp_serde::to_vec(&legacy).expect("encode legacy header"); + + let decoded: PlaylistItemHeader = rmp_serde::from_slice(&bytes).expect("legacy header still decodes"); + + assert_eq!(decoded.logo, "https://cdn.example.com/logo.png".intern()); + } +} diff --git a/shared/src/model/resource.rs b/shared/src/model/resource.rs new file mode 100644 index 000000000..73030cd58 --- /dev/null +++ b/shared/src/model/resource.rs @@ -0,0 +1,350 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{fmt, sync::Arc}; +use url::Url; + +pub const RESOURCE_LOCATOR_PREFIX: &str = "resource://v1/"; +const RESOURCE_SCHEME_PREFIX: &str = "resource://"; +const MAX_RESOURCE_LOCATOR_ENCODED_BYTES: usize = 16 * 1024; +const MAX_RESOURCE_LOCATOR_PAYLOAD_BYTES: usize = 12 * 1024; + +/// Internal representation of a HTTP(S) resource and the canonical input that supplied it. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ResourceLocator { + #[serde(with = "crate::utils::arc_str_serde")] + pub input_name: Arc, + #[serde(with = "crate::utils::arc_str_serde")] + pub url: Arc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceLocatorError { + MissingPrefix, + TooLarge, + InvalidEncoding, + InvalidPayload, + EmptyInput, + UnsupportedScheme, +} + +impl fmt::Display for ResourceLocatorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingPrefix => f.write_str("resource locator has no supported prefix"), + Self::TooLarge => f.write_str("resource locator exceeds the size limit"), + Self::InvalidEncoding => f.write_str("resource locator encoding is invalid"), + Self::InvalidPayload => f.write_str("resource locator payload is invalid"), + Self::EmptyInput => f.write_str("resource locator input is empty"), + Self::UnsupportedScheme => f.write_str("resource locator URL scheme is unsupported"), + } + } +} + +impl std::error::Error for ResourceLocatorError {} + +impl ResourceLocator { + pub fn new(input_name: Arc, url: Arc) -> Result { + if input_name.is_empty() { + return Err(ResourceLocatorError::EmptyInput); + } + if !is_http_resource_url(&url) { + return Err(ResourceLocatorError::UnsupportedScheme); + } + Ok(Self { input_name, url }) + } + + pub fn encode(&self) -> Result, ResourceLocatorError> { + let payload = rmp_serde::to_vec(self).map_err(|_| ResourceLocatorError::InvalidPayload)?; + if payload.len() > MAX_RESOURCE_LOCATOR_PAYLOAD_BYTES { + return Err(ResourceLocatorError::TooLarge); + } + let encoded = URL_SAFE_NO_PAD.encode(payload); + if encoded.len() > MAX_RESOURCE_LOCATOR_ENCODED_BYTES { + return Err(ResourceLocatorError::TooLarge); + } + Ok(format!("{RESOURCE_LOCATOR_PREFIX}{encoded}").into()) + } + + pub fn decode(value: &str) -> Result { + let encoded = value.strip_prefix(RESOURCE_LOCATOR_PREFIX).ok_or(ResourceLocatorError::MissingPrefix)?; + if encoded.is_empty() { + return Err(ResourceLocatorError::InvalidEncoding); + } + if encoded.len() > MAX_RESOURCE_LOCATOR_ENCODED_BYTES { + return Err(ResourceLocatorError::TooLarge); + } + let payload = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| ResourceLocatorError::InvalidEncoding)?; + if payload.len() > MAX_RESOURCE_LOCATOR_PAYLOAD_BYTES { + return Err(ResourceLocatorError::TooLarge); + } + let locator = rmp_serde::from_slice::(&payload).map_err(|_| ResourceLocatorError::InvalidPayload)?; + Self::new(locator.input_name, locator.url) + } +} + +pub fn has_resource_scheme(value: &str) -> bool { value.starts_with(RESOURCE_SCHEME_PREFIX) } + +/// Provider-facing serde formats are human-readable (JSON/XML adapters). Reserved locators are +/// accepted only from the binary repository representation and are also checked by the explicit +/// ingestion traversal used by manual parsers. +pub fn deserialize_untrusted_resource_arc<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let human_readable = deserializer.is_human_readable(); + let mut value = crate::utils::arc_str_none_default_on_null(deserializer)?; + if human_readable && has_resource_scheme(&value) { + value = Arc::from(""); + } + Ok(value) +} + +pub fn deserialize_untrusted_resource_option<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let human_readable = deserializer.is_human_readable(); + let mut value = crate::utils::deserialize_as_option_arc_str(deserializer)?; + if human_readable && value.as_deref().is_some_and(has_resource_scheme) { + value = None; + } + Ok(value) +} + +pub fn deserialize_untrusted_resource_array<'de, D>(deserializer: D) -> Result>>, D::Error> +where + D: Deserializer<'de>, +{ + let human_readable = deserializer.is_human_readable(); + let mut values = crate::utils::deserialize_as_string_array(deserializer)?; + if human_readable { + if let Some(values) = &mut values { + values.retain(|value| !has_resource_scheme(value)); + } + } + Ok(values) +} + +pub fn is_http_resource_url(value: &str) -> bool { + Url::parse(value).is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host().is_some()) +} + +/// Converts one untrusted provider value into the trusted internal representation. +/// Reserved locators are cleared so provider data can never make an authorization claim. +pub fn ingest_resource_value(value: &mut Arc, input_name: &Arc) -> Result<(), ResourceLocatorError> { + if value.is_empty() { + return Ok(()); + } + if has_resource_scheme(value) { + *value = Arc::from(""); + return Err(ResourceLocatorError::UnsupportedScheme); + } + if is_http_resource_url(value) { + *value = ResourceLocator::new(Arc::clone(input_name), Arc::clone(value))?.encode()?; + } + Ok(()) +} + +pub fn ingest_optional_resource_value( + value: &mut Option>, + input_name: &Arc, +) -> Result<(), ResourceLocatorError> { + if let Some(resource) = value { + if let Err(error) = ingest_resource_value(resource, input_name) { + *value = None; + return Err(error); + } + } + Ok(()) +} + +/// Normalizes values after trusted internal transformations. Existing valid locators are +/// preserved; newly produced raw HTTP(S) values become owned by `input_name`. +pub fn normalize_internal_resource_value( + value: &mut Arc, + input_name: &Arc, +) -> Result<(), ResourceLocatorError> { + if value.starts_with(RESOURCE_LOCATOR_PREFIX) { + ResourceLocator::decode(value)?; + return Ok(()); + } + ingest_resource_value(value, input_name) +} + +pub fn resolve_resource_value(value: &str) -> Result, ResourceLocatorError> { + if value.starts_with(RESOURCE_LOCATOR_PREFIX) { + return ResourceLocator::decode(value).map(Some); + } + if has_resource_scheme(value) { + return Err(ResourceLocatorError::MissingPrefix); + } + Ok(None) +} + +/// Converts the internal representation to the value safe for external output. Malformed +/// reserved values fail closed instead of leaking the internal scheme. +pub fn external_resource_value(value: &str) -> Arc { + match resolve_resource_value(value) { + Ok(Some(locator)) => locator.url, + Ok(None) => Arc::from(value), + Err(_) => Arc::from(""), + } +} + +/// Authenticated payload of a rewritten resource link. +/// +/// Routes whose path holds only an encoded URL have no stored record to read an origin from, so +/// the origin travels inside the link. The payload is authenticated, which also makes it +/// distinguishable from the two legacy encodings that carry a bare URL. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceToken { + pub resource: String, +} + +#[cfg(test)] +mod tests { + use super::{ + external_resource_value, has_resource_scheme, ingest_resource_value, ResourceLocator, ResourceLocatorError, + MAX_RESOURCE_LOCATOR_ENCODED_BYTES, RESOURCE_LOCATOR_PREFIX, + }; + use crate::model::{LiveStreamProperties, StalkerPlaylistItem, VideoStreamProperties}; + use std::sync::Arc; + + #[test] + fn locator_round_trips_http_urls_losslessly() { + for url in [ + "http://example.com/image.png", + "https://user:password@example.com:8443/a@b?q=a:b/c#fragment", + "https://[2001:db8::1]:8443/image.png", + "https://example.com/%E2%98%83?q=%C3%A4", + ] { + let locator = ResourceLocator::new(Arc::from("input:@/ä"), Arc::from(url)).expect("valid locator"); + let encoded = locator.encode().expect("encode locator"); + assert!(encoded.starts_with(RESOURCE_LOCATOR_PREFIX), "encoded starts with prefix"); + assert_eq!(ResourceLocator::decode(&encoded).expect("decode locator"), locator); + } + } + + #[test] + fn locator_rejects_malformed_and_unsupported_values() { + assert_eq!(ResourceLocator::decode("resource://v2/AAAA"), Err(ResourceLocatorError::MissingPrefix)); + assert_eq!(ResourceLocator::decode("resource://v1/***"), Err(ResourceLocatorError::InvalidEncoding)); + assert_eq!( + ResourceLocator::new(Arc::from(""), Arc::from("https://example.com/a")), + Err(ResourceLocatorError::EmptyInput) + ); + assert_eq!( + ResourceLocator::new(Arc::from("input"), Arc::from("")), + Err(ResourceLocatorError::UnsupportedScheme) + ); + for url in ["resource://v1/AAAA", "file:///tmp/a", "provider://a", "batch://a", "media-server://image/a"] { + assert_eq!( + ResourceLocator::new(Arc::from("input"), Arc::from(url)), + Err(ResourceLocatorError::UnsupportedScheme) + ); + } + let oversized = format!("resource://v1/{}", "A".repeat(MAX_RESOURCE_LOCATOR_ENCODED_BYTES + 1)); + assert_eq!(ResourceLocator::decode(&oversized), Err(ResourceLocatorError::TooLarge)); + } + + #[test] + fn provider_values_cannot_supply_internal_locators() { + let locator = ResourceLocator::new(Arc::from("other-input"), Arc::from("https://private.example/a")) + .expect("locator") + .encode() + .expect("encode"); + let mut forged = locator; + assert!(ingest_resource_value(&mut forged, &Arc::from("provider-input")).is_err()); + assert!(forged.is_empty()); + assert!(has_resource_scheme("resource://v99/value")); + } + + #[test] + fn ingest_wraps_http_and_preserves_non_http_values() { + let input = Arc::from("test-input"); + + // HTTP URL + let mut http_val = Arc::from("https://cdn.example.com/logo.png"); + assert!(ingest_resource_value(&mut http_val, &input).is_ok()); + assert!(http_val.starts_with(RESOURCE_LOCATOR_PREFIX)); + let decoded = ResourceLocator::decode(&http_val).expect("decode http"); + assert_eq!(decoded.url.as_ref(), "https://cdn.example.com/logo.png"); + assert_eq!(decoded.input_name.as_ref(), "test-input"); + + // Dedicated non-HTTP schemes stay on their existing serving paths. + let mut ms_val = Arc::from("media-server://image/plex/logo"); + assert!(ingest_resource_value(&mut ms_val, &input).is_ok()); + assert_eq!(ms_val.as_ref(), "media-server://image/plex/logo"); + + // Local/internal paths are not proxied HTTP resources. + let mut path_val = Arc::from("/path/to/logo.png"); + assert!(ingest_resource_value(&mut path_val, &input).is_ok()); + assert_eq!(path_val.as_ref(), "/path/to/logo.png"); + + // Empty stays empty + let mut empty_val = Arc::from(""); + assert!(ingest_resource_value(&mut empty_val, &input).is_ok()); + assert!(empty_val.is_empty()); + } + + #[test] + fn external_resource_value_unwraps_locator() { + let input = Arc::from("test-input"); + let url = "https://cdn.example.com/logo.png"; + let locator = ResourceLocator::new(input, Arc::from(url)).and_then(|l| l.encode()).expect("encode"); + assert_eq!(external_resource_value(&locator).as_ref(), url); + } + + #[test] + fn human_readable_provider_models_reject_reserved_locators() { + let locator = ResourceLocator::new(Arc::from("other-input"), Arc::from("https://private.example/a")) + .and_then(|locator| locator.encode()) + .expect("locator"); + + let live: LiveStreamProperties = + serde_json::from_value(serde_json::json!({ "stream_icon": locator })).expect("live JSON"); + assert!(live.stream_icon.is_empty()); + + let video: VideoStreamProperties = serde_json::from_value(serde_json::json!({ + "stream_icon": locator, + "details": { + "cover_big": locator, + "movie_image": locator, + "backdrop_path": [locator] + } + })) + .expect("video JSON"); + assert!(video.stream_icon.is_empty()); + let details = video.details.expect("details"); + assert!(details.cover_big.is_none()); + assert!(details.movie_image.is_none()); + assert!(details.backdrop_path.is_some_and(|values| values.is_empty())); + + let stalker: StalkerPlaylistItem = serde_json::from_value(serde_json::json!({ + "stream_id": 1, + "name": "name", + "category_id": 1, + "category_name": "group", + "number": 1, + "logo_url": locator, + "stream_url": "https://example.com/live", + "stream_kind": "live", + "cmd": "" + })) + .expect("Stalker JSON"); + assert!(stalker.logo_url.is_none()); + } + + #[test] + fn binary_repository_values_preserve_internal_locators() { + let locator = ResourceLocator::new(Arc::from("input"), Arc::from("https://example.com/logo.png")) + .and_then(|locator| locator.encode()) + .expect("locator"); + let properties = LiveStreamProperties { stream_icon: Arc::clone(&locator), ..LiveStreamProperties::default() }; + + let encoded = rmp_serde::to_vec(&properties).expect("serialize repository value"); + let decoded: LiveStreamProperties = rmp_serde::from_slice(&encoded).expect("deserialize repository value"); + + assert_eq!(decoded.stream_icon, locator); + } +} diff --git a/shared/src/model/stalker_item.rs b/shared/src/model/stalker_item.rs index cb278f55b..68d18fbfc 100644 --- a/shared/src/model/stalker_item.rs +++ b/shared/src/model/stalker_item.rs @@ -24,7 +24,7 @@ pub struct StalkerPlaylistItem { #[serde(with = "arc_str_serde")] pub category_name: Arc, pub number: u32, - #[serde(default)] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub logo_url: Option>, #[serde(default)] pub epg_channel_id: Option>, @@ -53,7 +53,7 @@ pub struct StalkerPlaylistItem { pub rating: f32, #[serde(default)] pub tmdb_id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub backdrop_url: Option>, /// Unix timestamp in seconds when the item was first persisted. #[serde(default)] @@ -138,6 +138,19 @@ impl Default for StalkerPlaylistItem { } impl StalkerPlaylistItem { + pub fn ingest_resource_values(&mut self, input_name: &Arc) -> usize { + let mut rejected = 0; + for value in [&mut self.logo_url, &mut self.backdrop_url] { + if let Some(resource) = value { + if crate::model::ingest_resource_value(resource, input_name).is_err() { + *value = None; + rejected += 1; + } + } + } + rejected + } + /// Whether this item is a series root (no individual playback URL). pub fn is_series_root(&self) -> bool { self.is_series } @@ -162,12 +175,23 @@ pub struct StalkerSeasonItem { pub season_number: i32, #[serde(with = "arc_str_serde")] pub name: Arc, - #[serde(default)] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub cover_url: Option>, #[serde(with = "arc_str_vec_serde", default)] pub episodes: Vec>, } +impl StalkerSeasonItem { + pub fn ingest_resource_values(&mut self, input_name: &Arc) -> usize { + let Some(cover) = &mut self.cover_url else { return 0 }; + if crate::model::ingest_resource_value(cover, input_name).is_err() { + self.cover_url = None; + return 1; + } + 0 + } +} + /// Lightweight episode record that keeps the actual `StalkerPlaylistItem` /// in the live/episode store and only embeds the season-level summary here. /// diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 72debdf2a..905f7a388 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -2,9 +2,9 @@ use crate::{ model::{info_doc_utils::InfoDocUtils, PlaylistEntry, XtreamSeriesInfo, XtreamSeriesInfoDoc, XtreamVideoInfo}, utils::{ arc_str_none_default_on_null, arc_str_null_is_none_serde, arc_str_option_null_if_empty_serde, - arc_str_option_serde, deserialize_as_option_arc_str, deserialize_as_string_array, - deserialize_json_as_opt_string, deserialize_number_from_string, deserialize_number_from_string_or_zero, - serialize_json_as_opt_string, Internable, CONSTANTS, + arc_str_option_serde, deserialize_as_option_arc_str, deserialize_json_as_opt_string, + deserialize_number_from_string, deserialize_number_from_string_or_zero, serialize_json_as_opt_string, + Internable, CONSTANTS, }, }; use log::warn; @@ -138,7 +138,7 @@ pub struct LiveStreamProperties { pub category_id: u32, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub stream_id: u32, - #[serde(default, deserialize_with = "arc_str_none_default_on_null")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_arc")] pub stream_icon: Arc, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub direct_source: Arc, @@ -219,9 +219,9 @@ pub struct VideoStreamDetailProperties { pub kinopoisk_url: Option>, #[serde(default, with = "arc_str_option_serde")] pub o_name: Option>, - #[serde(default, with = "arc_str_option_serde")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub cover_big: Option>, - #[serde(default, with = "arc_str_option_serde")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub movie_image: Option>, #[serde(default, with = "arc_str_option_serde")] pub release_date: Option>, @@ -249,7 +249,7 @@ pub struct VideoStreamDetailProperties { pub country: Option>, #[serde(default, with = "arc_str_option_serde")] pub genre: Option>, - #[serde(default, deserialize_with = "deserialize_as_string_array")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_array")] pub backdrop_path: Option>>, #[serde(default, with = "arc_str_option_serde")] pub duration_secs: Option>, @@ -283,7 +283,7 @@ pub struct VideoStreamProperties { pub category_id: u32, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub stream_id: u32, - #[serde(default, deserialize_with = "arc_str_none_default_on_null")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_arc")] pub stream_icon: Arc, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub direct_source: Arc, @@ -321,11 +321,11 @@ pub struct SeriesStreamDetailSeasonProperties { pub overview: Option>, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub air_date: Option>, - #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub cover: Option>, - #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub cover_tmdb: Option>, - #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_option")] pub cover_big: Option>, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub duration: Option>, @@ -363,7 +363,7 @@ pub struct SeriesStreamDetailEpisodeProperties { pub duration_secs: u32, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub duration: Arc, - #[serde(default, deserialize_with = "arc_str_none_default_on_null")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_arc")] pub movie_image: Arc, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub bitrate: u32, @@ -413,11 +413,11 @@ pub struct SeriesStreamProperties { pub category_id: u32, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub series_id: u32, - #[serde(default, deserialize_with = "deserialize_as_string_array")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_array")] pub backdrop_path: Option>>, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub cast: Arc, - #[serde(default, deserialize_with = "arc_str_none_default_on_null")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_arc")] pub cover: Arc, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub director: Arc, @@ -461,7 +461,7 @@ pub struct EpisodeStreamProperties { pub plot: Option>, #[serde(default, deserialize_with = "deserialize_number_from_string")] pub tmdb: Option, - #[serde(default, deserialize_with = "arc_str_none_default_on_null")] + #[serde(default, deserialize_with = "crate::model::deserialize_untrusted_resource_arc")] pub movie_image: Arc, #[serde(default, deserialize_with = "arc_str_null_is_none_serde::deserialize")] pub container_extension: Arc, @@ -488,6 +488,117 @@ pub enum StreamProperties { } impl StreamProperties { + pub fn visit_resource_values(&self, visitor: &mut impl FnMut(&Arc)) { + fn visit_optional(value: &Option>, visitor: &mut impl FnMut(&Arc)) { + if let Some(value) = value { + visitor(value); + } + } + fn visit_many(values: &Option>>, visitor: &mut impl FnMut(&Arc)) { + if let Some(values) = values { + values.iter().for_each(visitor); + } + } + match self { + Self::Live(live) => visitor(&live.stream_icon), + Self::Video(video) => { + visitor(&video.stream_icon); + if let Some(details) = &video.details { + visit_optional(&details.cover_big, visitor); + visit_optional(&details.movie_image, visitor); + visit_many(&details.backdrop_path, visitor); + } + } + Self::Series(series) => { + visitor(&series.cover); + visit_many(&series.backdrop_path, visitor); + if let Some(details) = &series.details { + if let Some(seasons) = &details.seasons { + for season in seasons { + visit_optional(&season.cover, visitor); + visit_optional(&season.cover_tmdb, visitor); + visit_optional(&season.cover_big, visitor); + } + } + if let Some(episodes) = &details.episodes { + for episode in episodes { + visitor(&episode.movie_image); + } + } + } + } + Self::Episode(episode) => visitor(&episode.movie_image), + } + } + + /// Visits every resource value that can be selected by [`Self::resolve_resource_url`]. + /// Ingestion validation and endpoint lookup intentionally share this implementation surface. + pub fn visit_resource_values_mut(&mut self, visitor: &mut impl FnMut(&mut Arc)) { + fn visit_optional(value: &mut Option>, visitor: &mut impl FnMut(&mut Arc)) { + if let Some(value) = value { + visitor(value); + } + } + fn visit_many(values: &mut Option>>, visitor: &mut impl FnMut(&mut Arc)) { + if let Some(values) = values { + values.iter_mut().for_each(visitor); + } + } + + match self { + Self::Live(live) => visitor(&mut live.stream_icon), + Self::Video(video) => { + visitor(&mut video.stream_icon); + if let Some(details) = &mut video.details { + visit_optional(&mut details.cover_big, visitor); + visit_optional(&mut details.movie_image, visitor); + visit_many(&mut details.backdrop_path, visitor); + } + } + Self::Series(series) => { + visitor(&mut series.cover); + visit_many(&mut series.backdrop_path, visitor); + if let Some(details) = &mut series.details { + if let Some(seasons) = &mut details.seasons { + for season in seasons { + visit_optional(&mut season.cover, visitor); + visit_optional(&mut season.cover_tmdb, visitor); + visit_optional(&mut season.cover_big, visitor); + } + } + if let Some(episodes) = &mut details.episodes { + for episode in episodes { + visitor(&mut episode.movie_image); + } + } + } + } + Self::Episode(episode) => visitor(&mut episode.movie_image), + } + } + + /// Applies the provider-ingress boundary to all resource-bearing nested fields. + pub fn ingest_resource_values(&mut self, input_name: &Arc) -> usize { + let mut rejected = 0; + self.visit_resource_values_mut(&mut |value| { + if super::ingest_resource_value(value, input_name).is_err() { + rejected += 1; + } + }); + rejected + } + + pub fn normalize_internal_resource_values(&mut self, input_name: &Arc) -> usize { + let mut rejected = 0; + self.visit_resource_values_mut(&mut |value| { + if super::normalize_internal_resource_value(value, input_name).is_err() { + *value = Arc::from(""); + rejected += 1; + } + }); + rejected + } + fn episode_value(&self, selector: F) -> Option where F: FnOnce(&EpisodeStreamProperties) -> T, @@ -765,9 +876,6 @@ impl StreamProperties { if field == "cover_big" { return season.cover_big.as_ref().map(Arc::clone); } - if field == "overview" { - return season.overview.as_ref().map(Arc::clone); - } } } } @@ -799,6 +907,19 @@ impl StreamProperties { } } +/// Field names accepted by resource-proxy endpoints. This is deliberately defined beside the +/// resource traversal and selector so adding a new endpoint-visible resource has one review site. +pub fn is_resource_field_name(field: &str) -> bool { + if matches!(field, "logo" | "logo_small" | "cover" | "movie_image" | "nfo_cover_big" | "nfo_movie_image") + || field.starts_with("backdrop_path") + || field.starts_with("nfo_backdrop_path") + { + return true; + } + parse_season_field(field).is_some_and(|(_, field)| matches!(field.as_str(), "cover" | "cover_tmdb" | "cover_big")) + || parse_season_episode_field(field).is_some_and(|(_, _, field)| field == "movie_image") +} + fn parse_season_field(s: &str) -> Option<(u32, String)> { let mut parts = s.split('_');