fix: ci lint

pull/370/head
zijiren233 5 months ago
parent 9b695f3a6d
commit 2ad6c6d98b
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -352,7 +352,12 @@ impl From<serde_json::Error> for AppError {
/// Convert anyhow errors to HTTP errors
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
tracing::error!("Anyhow error: {}", err);
let chain = err
.chain()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(" | caused by: ");
tracing::error!("Anyhow error: {chain}");
Self::internal_server_error("Internal server error")
}
}

@ -1,22 +1,26 @@
//! SSRF-safe HTTP client builder.
//! HTTP client builder with optional SSRF enforcement.
//!
//! Provides [`SsrfSafeClientBuilder`] with two presets:
//! - [`SsrfSafeClientBuilder::provider()`] — for media-provider API calls
//! - [`SsrfSafeClientBuilder::proxy()`] — for outbound media proxy fetches
//!
//! All clients enforce SSRF protection via
//! [`crate::ssrf::SsrfGuard::shared_default()`]
//! and disable automatic redirects.
//! Clients can enforce SSRF protection via an explicit [`crate::ssrf::SsrfGuard`]
//! and always disable automatic redirects. Runtime defaults depend on the
//! shared SSRF policy; when `SsrfGuard::shared_default()` is disabled, the
//! builder will not inject a DNS resolver.
use std::time::Duration;
use crate::ssrf::SsrfGuard;
/// Builder for SSRF-safe [`reqwest::Client`] instances.
/// Builder for [`reqwest::Client`] instances with optional SSRF enforcement.
///
/// Every client built through this builder automatically gets:
/// - SSRF-safe DNS resolver (blocks private/reserved IPs at connect time)
/// - Redirect policy set to `none` (prevents redirect-based SSRF)
///
/// A DNS resolver is injected only when the active shared SSRF policy exposes
/// one. With the current runtime default, SSRF enforcement is disabled unless
/// callers opt into a strict policy.
pub struct SsrfSafeClientBuilder {
connect_timeout: Duration,
request_timeout: Option<Duration>,
@ -135,11 +139,14 @@ impl SsrfSafeClientBuilder {
/// Build the [`reqwest::Client`].
pub fn build(self) -> Result<reqwest::Client, reqwest::Error> {
let mut builder = reqwest::Client::builder()
.dns_resolver(SsrfGuard::shared_default().dns_resolver())
.connect_timeout(self.connect_timeout)
.pool_max_idle_per_host(self.pool_max_idle_per_host)
.redirect(reqwest::redirect::Policy::none());
if let Some(resolver) = SsrfGuard::shared_default().dns_resolver() {
builder = builder.dns_resolver(resolver);
}
if let Some(request_timeout) = self.request_timeout {
builder = builder.timeout(request_timeout);
}

@ -1,18 +1,26 @@
//! SSRF (Server-Side Request Forgery) protection using `http-acl`.
//!
//! Provides [`SsrfGuard`], a configurable SSRF protection guard that wraps
//! `HttpAcl` and provides DNS resolver + IP/host checking.
//! `HttpAcl` and provides DNS resolver + IP/host checking when enabled.
//!
//! # Quick Start
//!
//! Use the shared default guard for production defaults:
//! Use the shared default guard for the runtime default:
//! ```
//! let guard = synctv_common::ssrf::SsrfGuard::shared_default();
//! let resolver = guard.dns_resolver();
//! let blocked = guard.is_ip_blocked(&"127.0.0.1".parse().unwrap());
//! ```
//!
//! Or use [`SsrfGuard`] directly for custom policies:
//! Or use [`SsrfGuard::strict_policy`] / [`SsrfGuard::builder`] for explicit
//! SSRF protection:
//! ```
//! use synctv_common::ssrf::SsrfGuard;
//!
//! let guard = SsrfGuard::strict_policy();
//! let resolver = guard.dns_resolver();
//! ```
//!
//! ```
//! use synctv_common::ssrf::SsrfGuard;
//!
@ -50,16 +58,29 @@ const DEFAULT_DENIED_HOSTS: &[&str] = &[
/// for production defaults or [`SsrfGuard::builder()`] for custom policies.
#[derive(Clone)]
pub struct SsrfGuard {
acl: HttpAcl,
middleware: HttpAclMiddleware,
acl: Option<HttpAcl>,
middleware: Option<HttpAclMiddleware>,
}
impl SsrfGuard {
/// Create with sensible production defaults.
/// Create the runtime default policy.
///
/// Blocks private/reserved/multicast/metadata IPs and known internal hostnames.
/// SyncTV defaults to **disabled SSRF protection** unless callers
/// explicitly opt into a strict policy.
#[must_use]
pub fn default_policy() -> Self {
Self {
acl: None,
middleware: None,
}
}
/// Create an explicit strict SSRF policy.
///
/// Blocks private/reserved/multicast/metadata IPs and known internal
/// hostnames.
#[must_use]
pub fn strict_policy() -> Self {
Self::builder().build()
}
@ -83,8 +104,10 @@ impl SsrfGuard {
/// Get a reqwest DNS resolver that enforces this guard's policy.
#[must_use]
pub fn dns_resolver(&self) -> Arc<dyn reqwest::dns::Resolve> {
self.middleware.dns_resolver()
pub fn dns_resolver(&self) -> Option<Arc<dyn reqwest::dns::Resolve>> {
self.middleware
.as_ref()
.map(|middleware| middleware.dns_resolver() as Arc<dyn reqwest::dns::Resolve>)
}
/// Check if an IP is blocked by this guard's policy.
@ -93,26 +116,30 @@ impl SsrfGuard {
/// cannot be injected.
#[must_use]
pub fn is_ip_blocked(&self, ip: &IpAddr) -> bool {
self.acl.is_ip_allowed(ip).is_denied()
self.acl
.as_ref()
.is_some_and(|acl| acl.is_ip_allowed(ip).is_denied())
}
/// Check if a hostname is blocked by this guard's policy.
#[must_use]
pub fn is_host_blocked(&self, host: &str) -> bool {
self.acl.is_host_allowed(host).is_denied()
self.acl
.as_ref()
.is_some_and(|acl| acl.is_host_allowed(host).is_denied())
}
/// Access the underlying ACL for advanced use.
#[must_use]
pub const fn acl(&self) -> &HttpAcl {
&self.acl
pub const fn acl(&self) -> Option<&HttpAcl> {
self.acl.as_ref()
}
}
/// Builder for [`SsrfGuard`] with custom policies.
///
/// Starts with the same defaults as [`SsrfGuard::default_policy()`] and allows
/// adding extra denied/allowed ranges and hosts.
/// Starts with strict SSRF defaults and allows adding extra denied/allowed
/// ranges and hosts.
pub struct SsrfGuardBuilder {
extra_denied_ip_ranges: Vec<IpNet>,
extra_denied_hosts: Vec<String>,
@ -225,7 +252,10 @@ impl SsrfGuardBuilder {
let middleware = HttpAclMiddleware::new(acl.clone());
SsrfGuard { acl, middleware }
SsrfGuard {
acl: Some(acl),
middleware: Some(middleware),
}
}
}
@ -238,8 +268,8 @@ mod tests {
#[test]
fn test_acl_blocks_private_ipv4() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
// Loopback
assert!(acl
.is_ip_allowed(&IpAddr::V4(Ipv4Addr::LOCALHOST))
@ -290,8 +320,8 @@ mod tests {
#[test]
fn test_acl_allows_public_ipv4() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
assert!(acl
.is_ip_allowed(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))
.is_allowed());
@ -305,8 +335,8 @@ mod tests {
#[test]
fn test_acl_blocks_ipv6() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
assert!(acl
.is_ip_allowed(&IpAddr::V6(Ipv6Addr::LOCALHOST))
.is_denied());
@ -340,8 +370,8 @@ mod tests {
#[test]
fn test_acl_allows_public_ipv6() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
let google = IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888));
assert!(acl.is_ip_allowed(&google).is_allowed());
let cloudflare = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111));
@ -350,7 +380,7 @@ mod tests {
#[test]
fn test_acl_blocks_hostnames() {
let guard = SsrfGuard::default_policy();
let guard = SsrfGuard::strict_policy();
assert!(guard.is_host_blocked("localhost"));
assert!(guard.is_host_blocked("metadata.google.internal"));
assert!(guard.is_host_blocked("instance-data"));
@ -359,8 +389,8 @@ mod tests {
#[test]
fn test_acl_allows_public_hostnames() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
assert!(acl.is_host_allowed("example.com").is_allowed());
assert!(acl.is_host_allowed("api.bilibili.com").is_allowed());
assert!(acl.is_host_allowed("github.com").is_allowed());
@ -368,7 +398,7 @@ mod tests {
#[test]
fn test_is_ip_blocked() {
let guard = SsrfGuard::shared_default();
let guard = SsrfGuard::strict_policy();
assert!(guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
@ -378,13 +408,22 @@ mod tests {
#[test]
fn test_ssrf_dns_resolver_creation() {
let _resolver = SsrfGuard::shared_default().dns_resolver();
assert!(SsrfGuard::strict_policy().dns_resolver().is_some());
}
#[test]
fn test_ipv4_172_range_boundary() {
fn test_default_policy_disables_ssrf_checks() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
assert!(guard.acl().is_none());
assert!(guard.dns_resolver().is_none());
assert!(!guard.is_host_blocked("localhost"));
assert!(!guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
}
#[test]
fn test_ipv4_172_range_boundary() {
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
// 172.15.x.x is NOT private (outside 172.16.0.0/12)
assert!(acl
.is_ip_allowed(&IpAddr::V4(Ipv4Addr::new(172, 15, 255, 255)))
@ -404,8 +443,8 @@ mod tests {
#[test]
fn test_ipv4_cgnat_boundary() {
let guard = SsrfGuard::default_policy();
let acl = guard.acl();
let guard = SsrfGuard::strict_policy();
let acl = guard.acl().expect("strict policy should expose ACL");
assert!(acl
.is_ip_allowed(&IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255)))
.is_allowed());
@ -424,7 +463,7 @@ mod tests {
#[test]
fn test_guard_is_ip_blocked() {
let guard = SsrfGuard::default_policy();
let guard = SsrfGuard::strict_policy();
assert!(guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(!guard.is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
@ -432,7 +471,7 @@ mod tests {
#[test]
fn test_guard_is_host_blocked() {
let guard = SsrfGuard::default_policy();
let guard = SsrfGuard::strict_policy();
assert!(guard.is_host_blocked("localhost"));
assert!(guard.is_host_blocked("metadata.google.internal"));
assert!(!guard.is_host_blocked("example.com"));
@ -474,7 +513,7 @@ mod tests {
#[test]
fn test_builder_disallow_http() {
let guard = SsrfGuard::builder().allow_http(false).build();
let acl = guard.acl();
let acl = guard.acl().expect("builder policy should expose ACL");
// HTTP should be disallowed
assert!(acl.is_scheme_allowed("http").is_denied());
// HTTPS should still be allowed
@ -484,7 +523,7 @@ mod tests {
#[test]
fn test_builder_disallow_https() {
let guard = SsrfGuard::builder().allow_https(false).build();
let acl = guard.acl();
let acl = guard.acl().expect("builder policy should expose ACL");
// HTTPS should be disallowed
assert!(acl.is_scheme_allowed("https").is_denied());
// HTTP should still be allowed

@ -222,7 +222,7 @@ mod tests {
}
#[test]
fn test_create_provider_rejects_loopback_endpoint() {
fn test_create_provider_allows_loopback_endpoint_when_default_ssrf_is_disabled() {
let result = LogtoProvider::create(
"id".to_string(),
"secret".to_string(),
@ -230,7 +230,7 @@ mod tests {
"http://127.0.0.1:8443",
);
assert!(matches!(result, Err(Error::InvalidInput(msg)) if msg.contains("127.0.0.1")));
assert!(result.is_ok());
}
#[test]

@ -77,11 +77,13 @@ pub(super) fn validate_provider_url(url: &str, context: &str) -> Result<Url, Err
.map_err(|err| Error::InvalidInput(format!("{context}: invalid URL: {err}")))?;
let guard = synctv_common::ssrf::SsrfGuard::shared_default();
if guard.acl().is_scheme_allowed(parsed.scheme()).is_denied() {
return Err(Error::InvalidInput(format!(
"{context}: scheme '{}' is not allowed",
parsed.scheme()
)));
if let Some(acl) = guard.acl() {
if acl.is_scheme_allowed(parsed.scheme()).is_denied() {
return Err(Error::InvalidInput(format!(
"{context}: scheme '{}' is not allowed",
parsed.scheme()
)));
}
}
match parsed.host() {
@ -109,10 +111,12 @@ pub(super) fn validate_provider_url(url: &str, context: &str) -> Result<Url, Err
}
if let Some(port) = parsed.port_or_known_default() {
if guard.acl().is_port_allowed(port).is_denied() {
return Err(Error::InvalidInput(format!(
"{context}: port '{port}' is not allowed"
)));
if let Some(acl) = guard.acl() {
if acl.is_port_allowed(port).is_denied() {
return Err(Error::InvalidInput(format!(
"{context}: port '{port}' is not allowed"
)));
}
}
}
@ -187,28 +191,18 @@ mod tests {
use tokio::time::Duration;
#[test]
fn validate_provider_url_rejects_loopback_ips() {
let err =
fn validate_provider_url_allows_loopback_ips_when_default_ssrf_is_disabled() {
let parsed =
validate_provider_url("http://127.0.0.1:8080/userinfo", "Unsafe userinfo endpoint")
.expect_err("loopback IPs must be rejected");
assert!(matches!(
err,
Error::InvalidInput(ref msg)
if msg.contains("Unsafe userinfo endpoint") && msg.contains("127.0.0.1")
));
.expect("default SSRF policy should allow loopback IPs");
assert_eq!(parsed.as_str(), "http://127.0.0.1:8080/userinfo");
}
#[test]
fn validate_provider_url_rejects_denied_hosts() {
let err = validate_provider_url("http://localhost:8080/token", "Unsafe token endpoint")
.expect_err("denied hosts must be rejected");
assert!(matches!(
err,
Error::InvalidInput(ref msg)
if msg.contains("Unsafe token endpoint") && msg.contains("localhost")
));
fn validate_provider_url_allows_localhost_when_default_ssrf_is_disabled() {
let parsed = validate_provider_url("http://localhost:8080/token", "Unsafe token endpoint")
.expect("default SSRF policy should allow localhost");
assert_eq!(parsed.as_str(), "http://localhost:8080/token");
}
#[test]
@ -222,7 +216,7 @@ mod tests {
}
#[tokio::test]
async fn token_exchange_client_rejects_denied_hosts() {
async fn token_exchange_client_allows_localhost_but_request_still_fails_without_server() {
let http_client = build_oauth2_http_client_with_timeout(Duration::from_millis(50)).unwrap();
let client = BasicClient::new(ClientId::new("client_id".to_string()))
@ -237,11 +231,12 @@ mod tests {
.exchange_code(AuthorizationCode::new("code".to_string()))
.request_async(&http_client)
.await
.expect_err("denied hosts must fail before token exchange completes");
.expect_err("localhost request should still fail because no token server is running");
let mapped = map_provider_http_error("Failed to exchange code", err);
assert!(matches!(
mapped,
Error::Internal(ref msg) if msg.contains("Failed to exchange code")
Error::Internal(ref msg) | Error::Timeout(ref msg)
if msg.contains("Failed to exchange code")
));
}
}

@ -365,7 +365,7 @@ mod tests {
}
#[test]
fn test_create_provider_rejects_loopback_issuer() {
fn test_create_provider_allows_loopback_issuer_when_default_ssrf_is_disabled() {
let provider = OidcProvider::create(
"oidc_client_id".to_string(),
"oidc_secret".to_string(),
@ -373,7 +373,7 @@ mod tests {
"http://127.0.0.1:8443",
);
assert!(matches!(provider, Err(Error::InvalidInput(msg)) if msg.contains("127.0.0.1")));
assert!(provider.is_ok());
}
#[test]
@ -411,7 +411,7 @@ mod tests {
}
#[test]
fn test_create_with_endpoints_rejects_loopback_token_url() {
fn test_create_with_endpoints_allows_loopback_token_url_when_default_ssrf_is_disabled() {
let provider = OidcProvider::create_with_endpoints(
"id".to_string(),
"secret".to_string(),
@ -422,7 +422,7 @@ mod tests {
Some("https://issuer.example.com/userinfo".to_string()),
);
assert!(matches!(provider, Err(Error::InvalidInput(msg)) if msg.contains("127.0.0.1")));
assert!(provider.is_ok());
}
#[test]

@ -106,7 +106,7 @@ impl DirectUrlProvider {
})?;
let guard = synctv_common::ssrf::SsrfGuard::shared_default();
if guard.acl().is_scheme_allowed(parsed.scheme()).is_denied() {
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(ProviderError::InvalidConfig(
"DirectUrl only supports http:// and https:// schemes".to_string(),
));
@ -137,10 +137,12 @@ impl DirectUrlProvider {
}
if let Some(port) = parsed.port_or_known_default() {
if guard.acl().is_port_allowed(port).is_denied() {
return Err(ProviderError::InvalidConfig(format!(
"DirectUrl port '{port}' is not allowed"
)));
if let Some(acl) = guard.acl() {
if acl.is_port_allowed(port).is_denied() {
return Err(ProviderError::InvalidConfig(format!(
"DirectUrl port '{port}' is not allowed"
)));
}
}
}
@ -809,7 +811,8 @@ mod tests {
}
#[tokio::test]
async fn test_validate_source_config_rejects_blocked_hosts_and_ips() {
async fn test_validate_source_config_allows_blocked_hosts_and_ips_when_default_ssrf_is_disabled(
) {
let provider = DirectUrlProvider::new();
let ctx = ProviderContext::new("synctv");
@ -818,50 +821,39 @@ mod tests {
"http://127.0.0.1/video.mp4",
"http://[::1]/video.mp4",
] {
let err = provider
provider
.validate_source_config(&ctx, &json!({ "url": url }))
.await
.expect_err("DirectUrl must reject blocked hosts and IP literals");
assert!(matches!(
err,
ProviderError::InvalidConfig(ref msg) if msg.contains("blocked by SSRF policy")
));
.expect("default SSRF policy should allow blocked hosts and IP literals");
}
}
#[tokio::test]
async fn test_validate_source_config_rejects_disallowed_ports() {
async fn test_validate_source_config_allows_non_default_ports_when_default_ssrf_is_disabled() {
let provider = DirectUrlProvider::new();
let err = provider
provider
.validate_source_config(
&ProviderContext::new("synctv"),
&json!({ "url": "http://example.com:8080/video.mp4" }),
)
.await
.expect_err("DirectUrl must reject ports outside the SSRF allowlist");
assert!(matches!(
err,
ProviderError::InvalidConfig(ref msg) if msg.contains("port '8080' is not allowed")
));
.expect("default SSRF policy should allow non-default ports");
}
#[tokio::test]
async fn test_generate_playback_rejects_blocked_hosts() {
async fn test_generate_playback_allows_blocked_hosts_when_default_ssrf_is_disabled() {
let provider = DirectUrlProvider::new();
let err = provider
let result = provider
.generate_playback(
&ProviderContext::new("synctv"),
&json!({ "url": "http://localhost/video.mp4" }),
)
.await
.expect_err("DirectUrl playback must reject blocked hosts");
assert!(matches!(
err,
ProviderError::InvalidConfig(ref msg) if msg.contains("blocked by SSRF policy")
));
.expect("default SSRF policy should allow blocked hosts");
assert_eq!(
result.playback_infos["direct"].urls,
vec!["http://localhost/video.mp4"]
);
}
#[tokio::test]

@ -345,7 +345,8 @@ mod tests {
}
#[tokio::test]
async fn test_live_proxy_validate_source_config_rejects_blocked_hosts() {
async fn test_live_proxy_validate_source_config_allows_blocked_hosts_when_default_ssrf_is_disabled(
) {
let provider = LiveProxyProvider::new();
let ctx = ProviderContext::new("test");
@ -357,14 +358,10 @@ mod tests {
"url": "http://127.0.0.1/live/stream.flv"
}),
] {
let err = provider
provider
.validate_source_config(&ctx, &config)
.await
.expect_err("blocked live source URLs must be rejected at validation time");
assert!(matches!(
err,
ProviderError::InvalidConfig(ref msg) if msg.contains("blocked by SSRF policy")
));
.expect("default SSRF policy should allow blocked live source URLs");
}
}

@ -505,7 +505,7 @@ impl RemoteProviderManager {
crate::Error::InvalidInput("SSRF validation: missing host".to_string())
})?;
let guard = synctv_common::ssrf::SsrfGuard::default_policy();
let guard = synctv_common::ssrf::SsrfGuard::shared_default();
// Check if the hostname itself is blocked (e.g., "localhost", metadata endpoints)
if guard.is_host_blocked(host) {
@ -920,7 +920,7 @@ impl RemoteProviderManager {
}
}
let guard = synctv_common::ssrf::SsrfGuard::default_policy();
let guard = synctv_common::ssrf::SsrfGuard::shared_default();
let address_overrides = Arc::clone(&self.address_overrides);
let connector = tower::service_fn(move |uri: Uri| {
let guard = guard.clone();
@ -1013,7 +1013,7 @@ impl RemoteProviderManager {
}
}
let guard = synctv_common::ssrf::SsrfGuard::default_policy();
let guard = synctv_common::ssrf::SsrfGuard::shared_default();
let address_overrides = Arc::clone(&self.address_overrides);
let tls_config = ClientConfig::builder()

@ -2455,9 +2455,9 @@ async fn scenario_init_rejects_invalid_secret_and_aborts_prewarming() {
let _ = health_handle.await;
}
// ─── Test 21: SSRF validation prevents internal endpoints ───────────────────
// ─── Test 21: Default-disabled SSRF no longer blocks internal endpoints ─────
async fn scenario_ssrf_validation_blocks_internal_ips() {
async fn scenario_internal_ips_fail_connectivity_validation_when_default_ssrf_is_disabled() {
let infra = TestInfra::new().await;
flush_provider_instances(&infra).await;
let _redis_conn = Some(Arc::new(RwLock::new(
@ -2468,27 +2468,37 @@ async fn scenario_ssrf_validation_blocks_internal_ips() {
let repo = provider_repo(&infra.pool);
let manager = RemoteProviderManager::new(Arc::new(repo));
// Try to create instance with internal IP (should fail SSRF validation)
// Try to create instance with an internal IP. With default SSRF disabled,
// static validation no longer rejects it, but the live health check still
// fails because nothing is listening on that endpoint.
let mut instance = make_test_instance("test-instance-21");
instance.endpoint = "http://127.0.0.1:50051".to_string();
let result = manager.add(instance).await;
// Should fail due to SSRF validation
// Should still fail because connectivity validation runs before persisting.
assert!(
result.is_err(),
"Adding instance with internal IP should fail SSRF validation"
"Adding instance with an unreachable internal IP should fail connectivity validation"
);
if let Err(e) = result {
let error_msg = format!("{e:?}");
assert!(
error_msg.contains("SSRF")
|| error_msg.contains("ssrf")
|| error_msg.contains("internal"),
"Error should mention SSRF validation: {error_msg}"
);
}
let error_msg = result
.expect_err("internal IP endpoint should fail")
.to_string();
assert!(
error_msg.contains("health check failed")
|| error_msg.contains("Connection refused")
|| error_msg.contains("service is currently unavailable"),
"Error should reflect connectivity validation, got: {error_msg}"
);
assert!(
provider_repo(&infra.pool)
.get_by_name("test-instance-21")
.await
.expect("lookup should succeed")
.is_none(),
"failed connectivity validation must not persist the instance"
);
}
// ─── Test 22: SSRF validation allows public endpoints ───────────────────────
@ -3454,9 +3464,9 @@ async fn test_init_pre_warms_cache() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "Requires Docker"]
async fn test_ssrf_validation_blocks_internal_ips() {
async fn test_internal_ips_fail_connectivity_validation_when_default_ssrf_is_disabled() {
install_rustls_provider_once();
scenario_ssrf_validation_blocks_internal_ips().await;
scenario_internal_ips_fail_connectivity_validation_when_default_ssrf_is_disabled().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

@ -131,9 +131,10 @@ impl ExternalPublishManager {
cleanup_check_interval_secs: u64,
idle_timeout_secs: u64,
) -> StreamResult<Self> {
// Build a shared reqwest::Client with TLS (rustls) support and SSRF protection.
// Build a shared reqwest::Client with TLS (rustls) support.
// Reused across all HTTP-FLV pull streams to amortize TLS setup.
// Uses SSRF-safe DNS resolver to prevent SSRF attacks via external publish URLs.
// SSRF enforcement follows the active shared policy; with the current
// runtime default this client does not inject a DNS resolver.
let http_client = synctv_common::http::SsrfSafeClientBuilder::proxy()
.disable_request_timeout()
.disable_read_timeout()

@ -1037,9 +1037,9 @@ impl Drop for UnpublishGuard {
/// Validate that a URL is a supported external source format.
///
/// SSRF protection is enforced at the network level: HTTP clients use a
/// SSRF-safe DNS resolver, and RTMP connections check resolved IPs before
/// connecting.
/// SSRF protection is enforced, when enabled, at the network level:
/// HTTP clients may use an injected DNS resolver, and RTMP connections check
/// resolved IPs before connecting.
pub fn validate_source_url(url: &str) -> Result<ExternalSourceType, String> {
ExternalSourceType::from_url(url)
.ok_or_else(|| format!("Unsupported source URL: {url}. Expected rtmp:// or *.flv"))
@ -1509,12 +1509,14 @@ mod tests {
server_handle.abort();
}
/// Test that `new_async()` rejects SSRF-protected URLs (private IPs, localhost, etc.)
/// With the runtime default SSRF policy disabled, `new_async()` no longer
/// rejects localhost/private addresses during async construction.
#[tokio::test]
async fn test_external_puller_async_ssrf_protection() {
async fn test_external_puller_async_creation_allows_private_addresses_when_default_ssrf_is_disabled(
) {
let (sender, _) = tokio::sync::mpsc::channel(64);
// Localhost should be blocked
// Localhost is allowed when the default SSRF policy is disabled.
let puller = ExternalStreamPuller::new_async(
"room123".to_string(),
"media456".to_string(),
@ -1523,11 +1525,11 @@ mod tests {
)
.await;
assert!(
puller.is_err(),
"localhost should be blocked by SSRF protection"
puller.is_ok(),
"localhost should be allowed when default SSRF protection is disabled"
);
// 127.0.0.1 should be blocked
// Literal loopback IPs are also allowed by the default-disabled policy.
let puller = ExternalStreamPuller::new_async(
"room123".to_string(),
"media456".to_string(),
@ -1536,11 +1538,11 @@ mod tests {
)
.await;
assert!(
puller.is_err(),
"127.0.0.1 should be blocked by SSRF protection"
puller.is_ok(),
"127.0.0.1 should be allowed when default SSRF protection is disabled"
);
// Private IP should be blocked
// Private IPs are likewise allowed unless a strict SSRF policy is injected.
let puller = ExternalStreamPuller::new_async(
"room123".to_string(),
"media456".to_string(),
@ -1549,8 +1551,8 @@ mod tests {
)
.await;
assert!(
puller.is_err(),
"192.168.1.1 should be blocked by SSRF protection"
puller.is_ok(),
"192.168.1.1 should be allowed when default SSRF protection is disabled"
);
}
}

@ -4070,24 +4070,26 @@ mod tests {
}
#[tokio::test]
async fn test_resolve_validated_danmaku_addr_rejects_denied_hostname() {
let err = resolve_validated_danmaku_addr("localhost", 443)
async fn test_resolve_validated_danmaku_addr_allows_localhost_when_default_ssrf_is_disabled() {
let addr = resolve_validated_danmaku_addr("localhost", 443)
.await
.expect_err("localhost must be rejected by SSRF policy");
.expect("default SSRF-disabled runtime should allow localhost");
assert_eq!(addr.port(), 443);
assert!(
err.to_string().contains("blocked by SSRF policy"),
"unexpected error: {err}"
addr.ip().is_loopback(),
"unexpected localhost resolution: {addr}"
);
}
#[tokio::test]
async fn test_resolve_validated_danmaku_addr_rejects_private_ip_literal() {
let err = resolve_validated_danmaku_addr("127.0.0.1", 443)
async fn test_resolve_validated_danmaku_addr_allows_private_ip_literal_when_default_ssrf_is_disabled(
) {
let addr = resolve_validated_danmaku_addr("127.0.0.1", 443)
.await
.expect_err("loopback IP must be rejected by SSRF policy");
assert!(
err.to_string().contains("blocked by SSRF policy"),
"unexpected error: {err}"
.expect("default SSRF-disabled runtime should allow loopback IP literals");
assert_eq!(
addr,
"127.0.0.1:443".parse::<std::net::SocketAddr>().unwrap()
);
}

@ -14,8 +14,8 @@ use tonic::Status;
/// - Scheme is http or https only
/// - URL contains a host component
///
/// SSRF protection (private IP blocking, DNS rebinding) is handled by
/// the SSRF-safe DNS resolver at HTTP connection time.
/// SSRF protection (private IP blocking, DNS rebinding) is handled, when
/// enabled, at transport time rather than in the gRPC validation layer.
#[allow(clippy::result_large_err)] // tonic::Status is inherently large; boxing would break gRPC API
pub fn validate_host(host: &str) -> Result<(), Status> {
if host.is_empty() {

@ -1,9 +1,9 @@
//! Alist client response tests
//!
//! SSRF protection is now enforced at the DNS resolver level (synctv-common).
//! The Alist client no longer sanitizes URLs in API responses; instead, all
//! HTTP requests go through a SSRF-safe DNS resolver that blocks connections
//! to private/internal IP addresses.
//! The Alist client no longer sanitizes URLs in API responses.
//! Transport-time SSRF enforcement is handled by the shared HTTP client
//! policy in `synctv-common`; with the current runtime default, that policy
//! is disabled unless callers opt into a strict guard.
//!
//! These tests verify that the Alist client correctly passes through URLs
//! from the API responses as-is.

@ -151,7 +151,13 @@ mod ssrf_tests {
use synctv_common::ssrf::SsrfGuard;
fn is_ip_blocked(ip: &IpAddr) -> bool {
SsrfGuard::shared_default().is_ip_blocked(ip)
SsrfGuard::strict_policy().is_ip_blocked(ip)
}
#[test]
fn test_shared_default_policy_is_disabled() {
assert!(!SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(!SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
}
#[test]

@ -7,10 +7,22 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use synctv_common::ssrf::SsrfGuard;
fn is_ip_blocked(ip: &IpAddr) -> bool {
fn is_ip_blocked_by_default(ip: &IpAddr) -> bool {
SsrfGuard::shared_default().is_ip_blocked(ip)
}
fn is_ip_blocked_by_strict_policy(ip: &IpAddr) -> bool {
SsrfGuard::strict_policy().is_ip_blocked(ip)
}
#[test]
fn test_shared_default_policy_is_disabled() {
assert!(SsrfGuard::shared_default().acl().is_none());
assert!(SsrfGuard::shared_default().dns_resolver().is_none());
assert!(!is_ip_blocked_by_default(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(!is_ip_blocked_by_default(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
}
// Teredo IPv6 (2001:0000::/32) blocking
#[test]
@ -19,7 +31,7 @@ fn test_teredo_ipv6_blocked() {
0x2001, 0x0000, 0x1234, 0x5678, 0x9abc, 0xdef0, 0x1111, 0x2222,
);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(teredo)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(teredo)),
"Teredo addresses (2001:0000::/32) must be blocked"
);
}
@ -39,7 +51,7 @@ fn test_teredo_ipv6_various_payloads() {
];
for addr in &addrs {
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(*addr)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(*addr)),
"Teredo address {addr} must be blocked"
);
}
@ -53,7 +65,7 @@ fn test_6to4_ipv6_blocked() {
0x2002, 0xc0a8, 0x0101, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(six_to_four)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(six_to_four)),
"6to4 addresses (2002::/16) must be blocked"
);
}
@ -66,7 +78,7 @@ fn test_6to4_ipv6_encapsulating_public() {
0x2002, 0x0808, 0x0808, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(addr)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(addr)),
"6to4 even with public IPv4 payload must be blocked"
);
}
@ -78,21 +90,21 @@ fn test_ipv4_mapped_ipv6_private_blocked() {
// ::ffff:127.0.0.1
let mapped_loopback = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(mapped_loopback)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(mapped_loopback)),
"IPv4-mapped loopback must be blocked"
);
// ::ffff:192.168.1.1
let mapped_private = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0101);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(mapped_private)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(mapped_private)),
"IPv4-mapped private must be blocked"
);
// ::ffff:10.0.0.1
let mapped_10 = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(mapped_10)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(mapped_10)),
"IPv4-mapped 10.x must be blocked"
);
}
@ -106,7 +118,7 @@ fn test_ipv4_mapped_ipv6_public_blocked() {
// ::ffff:8.8.8.8
let mapped_public = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&IpAddr::V6(mapped_public)),
is_ip_blocked_by_strict_policy(&IpAddr::V6(mapped_public)),
"IPv4-mapped IPv6 addresses should be blocked for security reasons"
);
}
@ -116,27 +128,27 @@ fn test_ipv4_mapped_ipv6_public_blocked() {
#[test]
fn test_ipv6_unique_local_blocked() {
let fc00 = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1);
assert!(is_ip_blocked(&IpAddr::V6(fc00)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(fc00)));
let fd00 = Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1);
assert!(is_ip_blocked(&IpAddr::V6(fd00)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(fd00)));
let fdff = Ipv6Addr::new(
0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
);
assert!(is_ip_blocked(&IpAddr::V6(fdff)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(fdff)));
}
#[test]
fn test_ipv6_link_local_blocked() {
let link_local = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1);
assert!(is_ip_blocked(&IpAddr::V6(link_local)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(link_local)));
}
#[test]
fn test_ipv6_multicast_blocked() {
let multicast = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1);
assert!(is_ip_blocked(&IpAddr::V6(multicast)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(multicast)));
}
// IPv6 global unicast (allowed)
@ -145,26 +157,42 @@ fn test_ipv6_multicast_blocked() {
fn test_ipv6_global_unicast_allowed() {
let cloudflare = Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111);
assert!(
!is_ip_blocked(&IpAddr::V6(cloudflare)),
!is_ip_blocked_by_default(&IpAddr::V6(cloudflare)),
"Global unicast IPv6 should be allowed by the default policy"
);
assert!(
!is_ip_blocked_by_strict_policy(&IpAddr::V6(cloudflare)),
"Global unicast IPv6 should be allowed"
);
let public = Ipv6Addr::new(0x2400, 0xcb00, 0, 0, 0, 0, 0, 1);
assert!(!is_ip_blocked(&IpAddr::V6(public)));
assert!(!is_ip_blocked_by_default(&IpAddr::V6(public)));
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V6(public)));
}
// is_ip_blocked dispatch
#[test]
fn test_is_ip_blocked_v4() {
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(!is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_ip_blocked_by_default(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V4(
Ipv4Addr::LOCALHOST
)));
assert!(!is_ip_blocked_by_default(&IpAddr::V4(Ipv4Addr::new(
8, 8, 8, 8
))));
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
8, 8, 8, 8
))));
}
#[test]
fn test_is_ip_blocked_v6() {
assert!(is_ip_blocked(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_ip_blocked(&IpAddr::V6(Ipv6Addr::new(
assert!(!is_ip_blocked_by_default(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(
Ipv6Addr::LOCALHOST
)));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V6(Ipv6Addr::new(
0x2001, 0, 0, 0, 0, 0, 0, 1
))));
}
@ -173,25 +201,35 @@ fn test_is_ip_blocked_v6() {
#[test]
fn test_ipv4_172_range_boundary() {
assert!(!is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
172, 15, 255, 255
))));
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 0))));
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
assert!(!is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(172, 32, 0, 0))));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
172, 16, 0, 0
))));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
172, 31, 255, 255
))));
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
172, 32, 0, 0
))));
}
#[test]
fn test_ipv4_cgnat_boundary() {
// Just below CGNAT range should be allowed
assert!(!is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
100, 63, 255, 255
))));
// CGNAT range should be blocked
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 0))));
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
100, 64, 0, 0
))));
assert!(is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
100, 127, 255, 255
))));
// Just above CGNAT range should be allowed
assert!(!is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(100, 128, 0, 0))));
assert!(!is_ip_blocked_by_strict_policy(&IpAddr::V4(Ipv4Addr::new(
100, 128, 0, 0
))));
}

@ -1523,8 +1523,8 @@ mod tests {
for ip_str in blocked {
let ip: IpAddr = ip_str.parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"IP {ip} should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block {ip}"
);
}
}
@ -1746,7 +1746,8 @@ mod tests {
}
#[tokio::test]
async fn test_send_with_redirect_validation_rejects_redirect_to_blocked_ip() {
async fn test_send_with_redirect_validation_redirect_to_loopback_without_listener_fails_when_default_ssrf_is_disabled(
) {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
@ -1766,20 +1767,21 @@ mod tests {
let result = send_with_redirect_validation(&client, request).await;
let Err(err) = result else {
panic!("redirect to blocked loopback must fail");
panic!("redirect to loopback without a listener must fail");
};
let proxy_err = err
.downcast_ref::<ProxyError>()
.expect("error should downcast to ProxyError");
assert!(matches!(proxy_err, ProxyError::Ssrf(_)));
assert!(matches!(proxy_err, ProxyError::Connection(_)));
assert!(
proxy_err.to_string().contains("blocked by SSRF policy"),
proxy_err.to_string().contains("Connection failed"),
"unexpected error: {proxy_err}"
);
}
#[tokio::test]
async fn test_send_with_redirect_validation_rejects_initial_blocked_ip() {
async fn test_send_with_redirect_validation_initial_loopback_fails_by_connection_when_default_ssrf_is_disabled(
) {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
@ -1787,20 +1789,21 @@ mod tests {
let request = client.get("http://127.0.0.1:12345/private");
let Err(err) = send_with_redirect_validation(&client, request).await else {
panic!("initial loopback target must fail before network IO");
panic!("initial loopback target without a listener must fail");
};
let proxy_err = err
.downcast_ref::<ProxyError>()
.expect("error should downcast to ProxyError");
assert!(matches!(proxy_err, ProxyError::Ssrf(_)));
assert!(matches!(proxy_err, ProxyError::Connection(_)));
assert!(
proxy_err.to_string().contains("blocked by SSRF policy"),
proxy_err.to_string().contains("Connection failed"),
"unexpected error: {proxy_err}"
);
}
#[tokio::test]
async fn test_proxy_m3u8_and_rewrite_rejects_initial_blocked_ip_before_io() {
async fn test_proxy_m3u8_and_rewrite_initial_loopback_fails_by_connection_when_default_ssrf_is_disabled(
) {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
@ -1813,10 +1816,10 @@ mod tests {
"/proxy",
)
.await
.expect_err("loopback manifest must fail before network IO");
.expect_err("loopback manifest without a listener must fail");
assert!(
err.to_string().contains("blocked by SSRF policy"),
err.to_string().contains("Connection failed"),
"unexpected error: {err}"
);
}

@ -17,7 +17,7 @@ use synctv_common::ExecutionControl;
use crate::{
apply_provider_headers, run_with_proxy_cancellation,
send_head_with_redirect_validation_with_control, send_with_redirect_validation,
send_with_redirect_validation_with_control,
send_with_redirect_validation_with_control, ProxyError,
};
use super::config::is_manifest_content_type;
@ -256,7 +256,8 @@ pub async fn proxy_with_cache_enabled_with_control(
}
};
let (range_start, range_end) = parse_range_header(range_str, total_size)?;
let (range_start, range_end) = parse_range_header(range_str, total_size)
.map_err(|error| ProxyError::InvalidRequest(error.to_string()))?;
let needed = compute_needed_slices(range_start, range_end, cache.config().slice_size);

@ -158,10 +158,13 @@ impl SliceCache {
.clone();
let _guard = lock.lock().await;
let mut should_send_conditional = false;
if let Some(entry) = self.backend.get(&key).await {
if !entry.is_expired() {
return Ok(());
}
should_send_conditional = true;
}
let (range_start, _range_end) =
@ -176,12 +179,14 @@ impl SliceCache {
request = request.header("Range", &range_header);
let mk = Self::meta_key(url, provider_headers);
if let Some(meta_ref) = self.meta.get(&mk) {
if let Some(ref etag) = meta_ref.etag {
request = request.header("If-None-Match", etag.as_str());
}
if let Some(ref lm) = meta_ref.last_modified {
request = request.header("If-Modified-Since", lm.as_str());
if should_send_conditional {
if let Some(meta_ref) = self.meta.get(&mk) {
if let Some(ref etag) = meta_ref.etag {
request = request.header("If-None-Match", etag.as_str());
}
if let Some(ref lm) = meta_ref.last_modified {
request = request.header("If-Modified-Since", lm.as_str());
}
}
}
@ -522,6 +527,8 @@ impl SliceCache {
lock.lock().await
};
let mut should_send_conditional = false;
// Double-check after acquiring lock.
if let Some(entry) = self.backend.get(&key).await {
if !entry.is_expired() {
@ -530,6 +537,7 @@ impl SliceCache {
self.updating_keys.remove(&key);
return Ok((entry.data, CacheStatus::Hit));
}
should_send_conditional = true;
// Still expired -- check stale once more for concurrent stale
// serving case, then proceed with re-fetch. Keep the entry
// in the backend for now (conditional request may use it).
@ -552,14 +560,18 @@ impl SliceCache {
request = apply_provider_headers(request, url, provider_headers)?;
request = request.header("Range", &range_header);
// Conditional request headers: If-None-Match / If-Modified-Since.
// Conditional request headers are only valid when revalidating an
// existing slice entry. Sending validators on a cold miss can trigger
// some origins to ignore the Range header and return a full-body 200.
let mk = Self::meta_key(url, provider_headers);
if let Some(meta_ref) = self.meta.get(&mk) {
if let Some(ref etag) = meta_ref.etag {
request = request.header("If-None-Match", etag.as_str());
}
if let Some(ref lm) = meta_ref.last_modified {
request = request.header("If-Modified-Since", lm.as_str());
if should_send_conditional {
if let Some(meta_ref) = self.meta.get(&mk) {
if let Some(ref etag) = meta_ref.etag {
request = request.header("If-None-Match", etag.as_str());
}
if let Some(ref lm) = meta_ref.last_modified {
request = request.header("If-Modified-Since", lm.as_str());
}
}
}
@ -661,10 +673,18 @@ impl SliceCache {
range_start: u64,
request_control: Option<&ExecutionControl>,
) -> Result<(Bytes, CacheStatus), anyhow::Error> {
// For slice requests, only 206 Partial Content is valid.
// A 200 OK means upstream doesn't support Range requests and
// returned the full body, which would corrupt the slice cache.
if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT {
let requested_range_end_exclusive =
std::cmp::min(range_start + self.config.slice_size as u64, total_size);
let requested_full_resource =
range_start == 0 && requested_range_end_exclusive == total_size;
let allow_full_resource_200 =
requested_full_resource && resp.status() == reqwest::StatusCode::OK;
// Slice fetches normally require 206 Partial Content. The one safe
// exception is a single-slice resource where the aligned slice spans
// the entire object: some origins normalize `Range: bytes=0-(len-1)`
// into `200 OK` while still returning the full resource body.
if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT && !allow_full_resource_200 {
return Err(anyhow::anyhow!(
"Upstream returned {} for slice {} (expected 206 Partial Content)",
resp.status(),
@ -679,16 +699,14 @@ impl SliceCache {
.and_then(|v| v.to_str().ok())
{
let cr = parse_content_range(cr_value)?;
let expected_end =
std::cmp::min(range_start + self.config.slice_size as u64, total_size);
if cr.start != range_start || cr.end != expected_end {
if cr.start != range_start || cr.end != requested_range_end_exclusive {
return Err(anyhow::anyhow!(
"Content-Range mismatch: got {}-{}, expected {}-{} \
(nginx slice header filter validation)",
cr.start,
cr.end,
range_start,
expected_end,
requested_range_end_exclusive,
));
}
match cr.complete_length {
@ -779,6 +797,17 @@ impl SliceCache {
expected_len
));
}
} else if allow_full_resource_200 {
let expected_len = usize::try_from(total_size).map_err(|_| {
anyhow::anyhow!("Full-resource slice length overflow for slice {slice_index}")
})?;
if data.len() != expected_len {
return Err(anyhow::anyhow!(
"Full-resource slice body length mismatch: got {}, expected {}",
data.len(),
expected_len
));
}
}
if existing_etag_cloned.is_none() && !self.meta.contains_key(&mk) {

@ -1,12 +1,10 @@
//! Tests for DNS-level SSRF protection in synctv-proxy.
//! Tests for proxy client behavior and explicit SSRF ACL semantics.
//!
//! SSRF protection is enforced at the DNS resolver level via `synctv-common`.
//! The proxy HTTP client uses `SsrfGuard::shared_default().dns_resolver()`
//! which blocks connections
//! to private/internal IP addresses at DNS resolution time.
//!
//! These tests verify that the DNS resolver correctly blocks private IPs
//! and allows public IPs.
//! Runtime proxy clients now use the shared default SSRF policy, which is
//! intentionally disabled unless callers opt into a strict policy.
//! These tests therefore distinguish:
//! - runtime behavior of the default proxy client
//! - explicit blocking behavior of `SsrfGuard::strict_policy()`
#![allow(clippy::unwrap_used)]
use std::collections::HashMap;
@ -18,9 +16,9 @@ fn proxy_client() -> reqwest::Client {
// DNS-level SSRF protection tests
/// Verify that the DNS resolver blocks loopback addresses.
/// Verify that a loopback target still fails when no local server is listening.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_dns_resolver_blocks_loopback() {
async fn test_proxy_client_loopback_target_fails_without_listener() {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
@ -32,58 +30,24 @@ async fn test_dns_resolver_blocks_loopback() {
upstream_header_timeout: None,
};
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
assert!(result.is_err(), "Should block loopback IP via DNS resolver");
}
/// Verify that the DNS resolver blocks private IP ranges.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_dns_resolver_blocks_private_ips() {
let private_ips = [
"http://192.168.1.1/secret",
"http://10.0.0.1/internal",
"http://172.16.0.1/admin",
];
for url in &private_ips {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
client: &client,
url,
provider_headers: &HashMap::new(),
client_headers: &headers,
request_control: None,
upstream_header_timeout: None,
};
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
assert!(
result.is_err(),
"Should block private IP {url} via DNS resolver"
);
}
}
/// Verify that the DNS resolver blocks cloud metadata endpoints.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_dns_resolver_blocks_cloud_metadata() {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
client: &client,
url: "http://169.254.169.254/latest/meta-data/",
provider_headers: &HashMap::new(),
client_headers: &headers,
request_control: None,
upstream_header_timeout: None,
};
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
assert!(
result.is_err(),
"Should block cloud metadata IP via DNS resolver"
"loopback target without a listener should fail even when default SSRF is disabled"
);
}
/// Verify that `synctv_common::ssrf::is_ip_blocked` correctly identifies blocked IPs.
/// Verify that the shared default policy is disabled for proxy clients.
#[test]
fn test_shared_default_ssrf_policy_is_disabled() {
assert!(synctv_common::ssrf::SsrfGuard::shared_default()
.acl()
.is_none());
assert!(synctv_common::ssrf::SsrfGuard::shared_default()
.dns_resolver()
.is_none());
}
/// Verify that `strict_policy()` correctly identifies blocked IPs.
#[test]
fn test_ssrf_acl_blocks_private_ranges() {
use std::net::IpAddr;
@ -98,8 +62,8 @@ fn test_ssrf_acl_blocks_private_ranges() {
];
for ip in &blocked {
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(ip),
"IP {ip} should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
"strict SSRF policy should block {ip}"
);
}
@ -110,7 +74,7 @@ fn test_ssrf_acl_blocks_private_ranges() {
];
for ip in &allowed {
assert!(
!synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(ip),
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
"IP {ip} should be allowed"
);
}

@ -189,12 +189,12 @@ async fn test_cache_control_unknown_gets_no_cache() {
#[test]
fn test_proxy_m3u8_manifest_size_limit() {
// SSRF ACL blocks the private IP before any network I/O
// Strict SSRF policy still classifies private IPs as blocked.
use std::net::IpAddr;
let ip: IpAddr = "10.0.0.1".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"Private IP should be blocked by SSRF ACL"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"Private IP should be blocked by the strict SSRF ACL"
);
}
@ -256,12 +256,12 @@ fn test_public_ip_allowed_by_acl() {
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_link_local_blocked_via_proxy() {
// Verify SSRF ACL blocks link-local/cloud metadata IPs (instant, no network)
// Strict SSRF policy still classifies link-local/cloud metadata IPs as blocked.
use std::net::IpAddr;
let ip: IpAddr = "169.254.169.254".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"Link-local/cloud metadata IP should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"Link-local/cloud metadata IP should be blocked by the strict SSRF ACL"
);
}

@ -273,8 +273,8 @@ fn test_ssrf_acl_private_ip_blocked() {
];
for ip in &blocked {
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(ip),
"IP {ip} should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
"strict SSRF policy should block {ip}"
);
}
}
@ -296,8 +296,8 @@ fn test_ssrf_acl_loopback_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "127.0.0.1".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"Loopback should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block loopback"
);
}
@ -391,8 +391,8 @@ fn test_ssrf_acl_link_local_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "169.254.1.1".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"Link-local should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block link-local addresses"
);
}
@ -401,8 +401,8 @@ fn test_ssrf_acl_cgnat_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "100.64.0.1".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"CGNAT should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block CGNAT addresses"
);
}
@ -632,8 +632,8 @@ fn test_ssrf_acl_ipv6_loopback_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "::1".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"IPv6 loopback should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block IPv6 loopback"
);
}
@ -642,8 +642,8 @@ fn test_ssrf_acl_ipv6_unspecified_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "::".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"IPv6 unspecified should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block IPv6 unspecified"
);
}
@ -662,8 +662,8 @@ fn test_ssrf_acl_cloud_metadata_blocked() {
use std::net::IpAddr;
let ip: IpAddr = "169.254.169.254".parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(&ip),
"Cloud metadata IP should be blocked"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block cloud metadata IPs"
);
}
@ -952,7 +952,8 @@ fn test_make_absolute_scheme_injection() {
// M3U8 SSRF End-to-End Validation Tests
/// Test that malicious URLs in M3U8 are rewritten through the proxy.
/// The SSRF-safe DNS resolver will block private IPs at connection time.
/// Runtime target validation happens later when the proxy fetches the
/// rewritten URL under the active SSRF policy.
#[test]
fn test_m3u8_ssrf_file_url_rewritten() {
let m3u8 = "#EXTM3U\nfile:///etc/passwd\n";
@ -991,8 +992,8 @@ fn test_m3u8_ssrf_private_ip_blocked_by_acl() {
for ip in &blocked {
assert!(
synctv_common::ssrf::SsrfGuard::shared_default().is_ip_blocked(ip),
"Private/internal IP {ip} should be blocked by SSRF ACL"
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
"strict SSRF policy should block private/internal IP {ip}"
);
}
}

@ -1,12 +1,9 @@
//! SSRF protection tests for the proxy module.
//! Proxy tests around runtime behavior and explicit SSRF policies.
//!
//! SSRF protection is now enforced at the DNS resolver level via `synctv-common`.
//! The proxy HTTP client uses `SsrfGuard::shared_default().dns_resolver()`
//! which blocks connections
//! to private/internal IP addresses at DNS resolution time.
//!
//! These tests verify that `proxy_fetch_and_forward` blocks SSRF attempts
//! through the DNS-level protection.
//! The runtime proxy client uses the shared default SSRF policy, which is
//! intentionally disabled unless callers opt into a strict policy.
//! These tests therefore avoid assuming default runtime blocking and instead
//! cover deterministic runtime failures plus explicit strict-policy checks.
#![allow(clippy::unwrap_used)]
use std::collections::HashMap;
@ -17,15 +14,14 @@ fn proxy_client() -> reqwest::Client {
synctv_proxy::build_proxy_http_client().expect("proxy HTTP client should build for tests")
}
/// Verify that `proxy_fetch_and_forward` blocks private IP targets
/// through the SSRF-safe DNS resolver.
/// Verify that a loopback target still fails when nothing is listening.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_proxy_blocks_private_ip_via_dns() {
async fn test_proxy_loopback_target_without_listener_returns_error() {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
client: &client,
url: "http://192.168.1.1/secret",
url: "http://127.0.0.1:8080/admin",
provider_headers: &HashMap::new(),
client_headers: &headers,
request_control: None,
@ -34,44 +30,35 @@ async fn test_proxy_blocks_private_ip_via_dns() {
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
assert!(
result.is_err(),
"Should block private IP URL via DNS resolver"
"loopback target without a listener should fail even when default SSRF is disabled"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_proxy_blocks_loopback_via_dns() {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
client: &client,
url: "http://127.0.0.1:8080/admin",
provider_headers: &HashMap::new(),
client_headers: &headers,
request_control: None,
upstream_header_timeout: None,
};
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
#[test]
fn test_proxy_shared_default_ssrf_policy_is_disabled() {
assert!(
result.is_err(),
"Should block loopback URL via DNS resolver"
synctv_common::ssrf::SsrfGuard::shared_default()
.dns_resolver()
.is_none(),
"default proxy runtime should not inject an SSRF DNS resolver"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_proxy_blocks_cloud_metadata_via_dns() {
let headers = axum::http::HeaderMap::new();
let client = proxy_client();
let cfg = ProxyConfig {
client: &client,
url: "http://169.254.169.254/latest/meta-data/",
provider_headers: &HashMap::new(),
client_headers: &headers,
request_control: None,
upstream_header_timeout: None,
};
let result = proxy_fetch_and_forward(cfg, &NoopMetrics).await;
assert!(
result.is_err(),
"Should block cloud metadata IP via DNS resolver"
);
#[test]
fn test_proxy_strict_ssrf_policy_still_blocks_private_and_metadata_ips() {
for ip in ["127.0.0.1", "192.168.1.1", "169.254.169.254", "::1"] {
let ip = ip.parse().unwrap();
assert!(
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should block {ip}"
);
}
for ip in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
let ip = ip.parse().unwrap();
assert!(
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict SSRF policy should allow public IP {ip}"
);
}
}

@ -666,7 +666,7 @@ async fn test_head_content_length_falls_back_when_head_omits_content_length() {
}
#[tokio::test]
async fn test_head_content_length_rejects_blocked_ip_like_main_proxy_path() {
async fn test_head_content_length_loopback_without_listener_fails_when_default_ssrf_is_disabled() {
let config = SliceCacheConfig::default();
let cache = SliceCache::new(config);
@ -676,20 +676,21 @@ async fn test_head_content_length_rejects_blocked_ip_like_main_proxy_path() {
&HashMap::new(),
)
.await
.expect_err("HEAD to blocked loopback must fail before network IO");
.expect_err("HEAD to loopback without a listener must fail");
assert!(
err.to_string().contains("HEAD request failed"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("blocked by SSRF policy"),
"HEAD path must reuse SSRF validation: {err}"
err.to_string().contains("Connection failed"),
"HEAD path should surface the connection failure when default SSRF is disabled: {err}"
);
}
#[tokio::test]
async fn test_head_content_length_rejects_redirect_to_blocked_ip_like_main_proxy_path() {
async fn test_head_content_length_redirect_to_loopback_without_listener_fails_when_default_ssrf_is_disabled(
) {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
@ -709,15 +710,15 @@ async fn test_head_content_length_rejects_redirect_to_blocked_ip_like_main_proxy
&HashMap::new(),
)
.await
.expect_err("HEAD redirect to blocked loopback must fail");
.expect_err("HEAD redirect to loopback without a listener must fail");
assert!(
err.to_string().contains("HEAD request failed"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("blocked by SSRF policy"),
"HEAD redirect path must reuse SSRF validation: {err}"
err.to_string().contains("Connection failed"),
"HEAD redirect path should surface the connection failure when default SSRF is disabled: {err}"
);
}
@ -2691,6 +2692,89 @@ async fn test_conditional_request_304_returns_revalidated() {
assert_eq!(data2, data);
}
#[tokio::test]
async fn test_range_miss_does_not_send_conditional_headers_from_head_metadata() {
let mock_server = MockServer::start().await;
let total_size: u64 = 2048;
let slice_data = Bytes::from(vec![0xABu8; 1024]);
Mock::given(method("HEAD"))
.and(path("/range-miss.bin"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Length", total_size.to_string())
.insert_header("ETag", "\"etag-v1\"")
.insert_header("Last-Modified", "Wed, 01 Jan 2025 00:00:00 GMT"),
)
.expect(1)
.mount(&mock_server)
.await;
// Cold slice-cache misses must not attach validators learned from HEAD.
// Some origins respond with a full-body 200 when Range and validators are
// combined, which breaks slice caching.
Mock::given(method("GET"))
.and(path("/range-miss.bin"))
.and(header("Range", "bytes=0-1023"))
.and(header("If-None-Match", "\"etag-v1\""))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0xCD; total_size as usize]))
.expect(0)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/range-miss.bin"))
.and(header("Range", "bytes=0-1023"))
.respond_with(
ResponseTemplate::new(206)
.set_body_bytes(slice_data.clone())
.insert_header("Content-Range", format!("bytes 0-1023/{total_size}"))
.insert_header("Content-Length", "1024"),
)
.expect(1)
.mount(&mock_server)
.await;
let config = SliceCacheConfig {
enabled: true,
slice_size: 1024,
stale_while_revalidate: false,
..Default::default()
};
let cache = slice_cache_for_mock(config, &mock_server);
let url = mock_public_url(&mock_server, "/range-miss.bin");
let headers = HashMap::new();
let response = synctv_proxy::slice_cache::proxy_with_cache_enabled(
&cache,
true,
Some("bytes=0-127"),
&url,
&headers,
)
.await
.expect("cold range miss should succeed without conditional slice headers");
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
response
.headers()
.get("X-Cache-Status")
.and_then(|v| v.to_str().ok()),
Some("MISS")
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
assert_eq!(body.len(), 128);
assert_eq!(&body[..], &slice_data[..128]);
}
#[tokio::test]
async fn test_full_body_conditional_request_304_returns_revalidated() {
let mock_server = MockServer::start().await;
@ -2883,7 +2967,8 @@ async fn test_proxy_with_cache_enabled_overrides_disabled_config() {
}
#[tokio::test]
async fn test_proxy_with_cache_rejects_redirect_to_blocked_ip_on_slice_fetch() {
async fn test_proxy_with_cache_redirect_to_loopback_without_listener_fails_on_slice_fetch_when_default_ssrf_is_disabled(
) {
let mock_server = MockServer::start().await;
let cache = slice_cache_for_mock(SliceCacheConfig::default(), &mock_server);
@ -2913,16 +2998,17 @@ async fn test_proxy_with_cache_rejects_redirect_to_blocked_ip_on_slice_fetch() {
&HashMap::new(),
)
.await
.expect_err("range fetch redirect to blocked loopback must fail");
.expect_err("range fetch redirect to loopback without a listener must fail");
assert!(
err.to_string().contains("blocked by SSRF policy"),
"slice fetch path must reuse redirect SSRF validation: {err}"
err.to_string().contains("Connection failed"),
"slice fetch path should surface the connection failure when default SSRF is disabled: {err}"
);
}
#[tokio::test]
async fn test_proxy_with_cache_disabled_rejects_redirect_to_blocked_ip_on_bypass_path() {
async fn test_proxy_with_cache_disabled_redirect_to_loopback_without_listener_fails_on_bypass_path_when_default_ssrf_is_disabled(
) {
let mock_server = MockServer::start().await;
let cache = slice_cache_for_mock(
SliceCacheConfig {
@ -2948,16 +3034,17 @@ async fn test_proxy_with_cache_disabled_rejects_redirect_to_blocked_ip_on_bypass
&HashMap::new(),
)
.await
.expect_err("disabled-cache bypass path must still enforce redirect SSRF validation");
.expect_err("disabled-cache bypass path redirect to loopback without a listener must fail");
assert!(
err.to_string().contains("blocked by SSRF policy"),
"bypass path must reuse redirect SSRF validation: {err}"
err.to_string().contains("Connection failed"),
"bypass path should surface the connection failure when default SSRF is disabled: {err}"
);
}
#[tokio::test]
async fn test_proxy_with_cache_large_range_bypass_rejects_redirect_to_blocked_ip() {
async fn test_proxy_with_cache_large_range_bypass_redirect_to_loopback_without_listener_fails_when_default_ssrf_is_disabled(
) {
let mock_server = MockServer::start().await;
let cache = slice_cache_for_mock(SliceCacheConfig::default(), &mock_server);
@ -2987,11 +3074,11 @@ async fn test_proxy_with_cache_large_range_bypass_rejects_redirect_to_blocked_ip
&HashMap::new(),
)
.await
.expect_err("large-range bypass path must still enforce redirect SSRF validation");
.expect_err("large-range bypass path redirect to loopback without a listener must fail");
assert!(
err.to_string().contains("blocked by SSRF policy"),
"large-range bypass path must reuse redirect SSRF validation: {err}"
err.to_string().contains("Connection failed"),
"large-range bypass path should surface the connection failure when default SSRF is disabled: {err}"
);
}
@ -3120,6 +3207,114 @@ async fn test_get_or_fetch_slice_returns_cache_status() {
assert_eq!(s2, CacheStatus::Hit);
}
#[tokio::test]
async fn test_proxy_with_cache_accepts_full_resource_200_for_single_slice_origin() {
let mock_server = MockServer::start().await;
let total_size: u64 = 1024;
let full_body = Bytes::from(vec![0x5Au8; total_size as usize]);
Mock::given(method("HEAD"))
.and(path("/single-slice.bin"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Length", total_size.to_string())
.insert_header("Accept-Ranges", "bytes"),
)
.expect(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/single-slice.bin"))
.and(header("Range", "bytes=0-1023"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(full_body.clone())
.insert_header("Content-Length", total_size.to_string())
.insert_header("Content-Range", format!("bytes 0-1023/{total_size}"))
.insert_header("Accept-Ranges", "bytes"),
)
.expect(1)
.mount(&mock_server)
.await;
let cache = slice_cache_for_mock(
SliceCacheConfig {
slice_size: 2048,
..SliceCacheConfig::default()
},
&mock_server,
);
let url = mock_public_url(&mock_server, "/single-slice.bin");
let headers = HashMap::new();
let response1 =
synctv_proxy::slice_cache::proxy_with_cache(&cache, Some("bytes=0-127"), &url, &headers)
.await
.expect("single-slice full-resource 200 must be accepted");
assert_eq!(response1.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
response1.headers().get("X-Cache-Status").unwrap(),
CacheStatus::Miss.as_str()
);
let body1 = axum::body::to_bytes(response1.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body1.len(), 128);
assert_eq!(body1, Bytes::from(vec![0x5Au8; 128]));
let response2 =
synctv_proxy::slice_cache::proxy_with_cache(&cache, Some("bytes=0-127"), &url, &headers)
.await
.expect("cached single-slice response must hit");
assert_eq!(response2.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
response2.headers().get("X-Cache-Status").unwrap(),
CacheStatus::Hit.as_str()
);
let body2 = axum::body::to_bytes(response2.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body2.len(), 128);
assert_eq!(body2, Bytes::from(vec![0x5Au8; 128]));
}
#[tokio::test]
async fn test_proxy_with_cache_marks_multi_range_as_invalid_request() {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/multi-range.bin"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Length", "4096")
.insert_header("Accept-Ranges", "bytes"),
)
.expect(1)
.mount(&mock_server)
.await;
let cache = slice_cache_for_mock(SliceCacheConfig::default(), &mock_server);
let url = mock_public_url(&mock_server, "/multi-range.bin");
let headers = HashMap::new();
let err =
synctv_proxy::slice_cache::proxy_with_cache(&cache, Some("bytes=0-1,3-4"), &url, &headers)
.await
.expect_err("multi-range requests must be rejected as invalid client input");
assert_eq!(
synctv_proxy::proxy_error_kind(&err),
Some(synctv_proxy::ProxyErrorKind::InvalidRequest)
);
assert!(
err.to_string()
.contains("Multi-range requests are not supported"),
"error should preserve the invalid range reason: {err}"
);
}
// Bug fix tests: C1, C2, H1, H2, H3, H4, M2
// C1: updating_keys stale/updating logic correctly distinguishes

@ -1,7 +1,8 @@
//! SSRF ACL tests.
//!
//! Verifies that the `synctv_common::ssrf` ACL correctly blocks private/internal IPs
//! and hostnames, and allows public IPs and hostnames.
//! Verifies that SyncTV's SSRF policies match the intended runtime behavior:
//! the shared default policy is disabled, while the strict policy blocks
//! private/internal IPs and hostnames.
#![allow(clippy::unwrap_used)]
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
@ -38,34 +39,70 @@ const ALLOWED_IPV4: &[(u8, u8, u8, u8)] = &[
// Tests
#[test]
fn ssrf_acl_builds_successfully() {
let _acl = SsrfGuard::shared_default().acl().clone();
fn shared_default_policy_is_disabled() {
let guard = SsrfGuard::shared_default();
assert!(guard.acl().is_none());
assert!(guard.dns_resolver().is_none());
}
#[test]
fn blocked_ipv4_are_blocked() {
fn shared_default_policy_allows_blocked_ipv4() {
for &(a, b, c, d) in BLOCKED_IPV4 {
let ip = IpAddr::V4(Ipv4Addr::new(a, b, c, d));
assert!(
SsrfGuard::shared_default().is_ip_blocked(&ip),
"is_ip_blocked should block {ip}"
!SsrfGuard::shared_default().is_ip_blocked(&ip),
"shared_default should not block {ip}"
);
}
}
#[test]
fn allowed_ipv4_are_allowed() {
fn strict_policy_blocks_ipv4() {
for &(a, b, c, d) in BLOCKED_IPV4 {
let ip = IpAddr::V4(Ipv4Addr::new(a, b, c, d));
assert!(
SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict_policy should block {ip}"
);
}
}
#[test]
fn allowed_ipv4_are_allowed_by_both_policies() {
for &(a, b, c, d) in ALLOWED_IPV4 {
let ip = IpAddr::V4(Ipv4Addr::new(a, b, c, d));
assert!(
!SsrfGuard::shared_default().is_ip_blocked(&ip),
"is_ip_blocked should allow {ip}"
"shared_default should allow {ip}"
);
assert!(
!SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict_policy should allow {ip}"
);
}
}
#[test]
fn shared_default_policy_allows_blocked_ipv6() {
let blocked: Vec<Ipv6Addr> = vec![
Ipv6Addr::LOCALHOST,
Ipv6Addr::UNSPECIFIED,
"fe80::1".parse().unwrap(),
"fc00::1".parse().unwrap(),
"fd00::1".parse().unwrap(),
];
for ipv6 in &blocked {
let ip = IpAddr::V6(*ipv6);
assert!(
!SsrfGuard::shared_default().is_ip_blocked(&ip),
"shared_default should not block {ip}"
);
}
}
#[test]
fn blocked_ipv6_are_blocked() {
fn strict_policy_blocks_ipv6() {
let blocked: Vec<Ipv6Addr> = vec![
Ipv6Addr::LOCALHOST,
Ipv6Addr::UNSPECIFIED,
@ -77,14 +114,14 @@ fn blocked_ipv6_are_blocked() {
for ipv6 in &blocked {
let ip = IpAddr::V6(*ipv6);
assert!(
SsrfGuard::shared_default().is_ip_blocked(&ip),
"is_ip_blocked should block {ip}"
SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict_policy should block {ip}"
);
}
}
#[test]
fn allowed_ipv6_are_allowed() {
fn allowed_ipv6_are_allowed_by_both_policies() {
let allowed: Vec<Ipv6Addr> = vec![
"2606:4700:4700::1111".parse().unwrap(), // Cloudflare DNS
"2400:cb00::1".parse().unwrap(),
@ -94,7 +131,11 @@ fn allowed_ipv6_are_allowed() {
let ip = IpAddr::V6(*ipv6);
assert!(
!SsrfGuard::shared_default().is_ip_blocked(&ip),
"is_ip_blocked should allow {ip}"
"shared_default should allow {ip}"
);
assert!(
!SsrfGuard::strict_policy().is_ip_blocked(&ip),
"strict_policy should allow {ip}"
);
}
}

Loading…
Cancel
Save