mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1120 lines
34 KiB
Rust
1120 lines
34 KiB
Rust
//! Unit tests for pure functions in the synctv-proxy crate.
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
use std::collections::HashMap;
|
|
use std::fmt::Write as _;
|
|
use synctv_proxy::{apply_provider_headers, percent_encode, rewrite_m3u8};
|
|
|
|
// Helper: build a reqwest request and inspect the resulting headers
|
|
|
|
fn build_and_get_headers(
|
|
url: &str,
|
|
provider_headers: &HashMap<String, String>,
|
|
) -> reqwest::header::HeaderMap {
|
|
let client = reqwest::Client::new();
|
|
let request = client.get(url);
|
|
let request = apply_provider_headers(request, url, provider_headers)
|
|
.expect("provider headers should be valid");
|
|
let built = request.build().expect("Failed to build request");
|
|
built.headers().clone()
|
|
}
|
|
|
|
// apply_provider_headers
|
|
|
|
#[test]
|
|
fn test_default_user_agent_added_when_absent() {
|
|
let headers = build_and_get_headers("https://example.com/video.mp4", &HashMap::new());
|
|
let ua = headers
|
|
.get("user-agent")
|
|
.expect("User-Agent should be set by default");
|
|
assert!(
|
|
ua.to_str().unwrap().contains("Mozilla"),
|
|
"Default User-Agent should look like a browser"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_user_agent_not_overridden() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert("User-Agent".to_string(), "CustomAgent/1.0".to_string());
|
|
let headers = build_and_get_headers("https://example.com/video.mp4", &provider);
|
|
let ua = headers.get("user-agent").expect("User-Agent should exist");
|
|
assert_eq!(ua.to_str().unwrap(), "CustomAgent/1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_user_agent_case_insensitive_not_overridden() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert("user-agent".to_string(), "CustomAgent/2.0".to_string());
|
|
let headers = build_and_get_headers("https://example.com/video.mp4", &provider);
|
|
let ua = headers.get("user-agent").expect("User-Agent should exist");
|
|
assert_eq!(ua.to_str().unwrap(), "CustomAgent/2.0");
|
|
assert_eq!(headers.get_all("user-agent").iter().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_referer_not_synthesized_from_url() {
|
|
let headers =
|
|
build_and_get_headers("https://cdn.example.com/path/to/video.mp4", &HashMap::new());
|
|
assert!(
|
|
headers.get("referer").is_none(),
|
|
"Referer should only be sent when a provider explicitly supplies it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_referer_not_overridden() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert(
|
|
"Referer".to_string(),
|
|
"https://custom.example.com/".to_string(),
|
|
);
|
|
let headers = build_and_get_headers("https://cdn.example.com/video.mp4", &provider);
|
|
let referer = headers.get("referer").expect("Referer should exist");
|
|
assert_eq!(referer.to_str().unwrap(), "https://custom.example.com/");
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_referer_case_insensitive_not_overridden() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert(
|
|
"referer".to_string(),
|
|
"https://custom.example.com/lower".to_string(),
|
|
);
|
|
let headers = build_and_get_headers("https://cdn.example.com/video.mp4", &provider);
|
|
let referer = headers.get("referer").expect("Referer should exist");
|
|
assert_eq!(
|
|
referer.to_str().unwrap(),
|
|
"https://custom.example.com/lower"
|
|
);
|
|
assert_eq!(headers.get_all("referer").iter().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unparseable_url_no_referer_crash() {
|
|
// apply_provider_headers should not panic when url::Url::parse fails.
|
|
// We use a valid HTTP URL with a nonsense host so reqwest can build it,
|
|
// and pass a malformed URL string to apply_provider_headers directly.
|
|
let client = reqwest::Client::new();
|
|
let request = client.get("https://example.com/path");
|
|
let request = apply_provider_headers(request, ":::invalid", &HashMap::new())
|
|
.expect("empty provider headers should be accepted");
|
|
let built = request.build().expect("Request should still build");
|
|
assert!(
|
|
built.headers().get("referer").is_none(),
|
|
"Referer should not be synthesized from any URL"
|
|
);
|
|
}
|
|
|
|
// rewrite_m3u8
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_absolute_segment_unchanged() {
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/seg1.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://origin.example.com/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// The absolute URL should be percent-encoded in the url= parameter
|
|
assert!(rewritten.contains("url=https%3A%2F%2Fcdn%2Eexample%2Ecom%2Fseg1%2Ets"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_removes_huya_extinf_query_pollution() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXTINF:1.068,\n",
|
|
"https://cdn.example.com/live.ts?wsSecret=abc&ratio=4000&EXTINF=1.068\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://origin.example.com/live.m3u8",
|
|
"/proxy/segments",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(rewritten.contains("wsSecret%3Dabc%26ratio%3D4000"));
|
|
assert!(!rewritten.contains("EXTINF%3D1%2E068"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_ext_x_key_uri_rewritten() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"\n",
|
|
"seg1.ts\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/path/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// The URI in EXT-X-KEY should be rewritten to an absolute proxied URL
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"EXT-X-KEY URI should be rewritten, got: {rewritten}"
|
|
);
|
|
// The key URL should be made absolute against the source base
|
|
assert!(
|
|
rewritten.contains("cdn%2Eexample%2Ecom"),
|
|
"Key URL should contain the CDN host, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_ext_x_media_uri_rewritten() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio/en.m3u8\"\n",
|
|
"video.m3u8\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"EXT-X-MEDIA URI should be rewritten, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_proxy_base_with_query_uses_ampersand() {
|
|
let m3u8 = "#EXTM3U\nseg1.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/path/master.m3u8",
|
|
"/proxy/stream?provider=abc",
|
|
)
|
|
.unwrap();
|
|
// When proxy_base already has a '?', the separator should be '&'
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?provider=abc&url="),
|
|
"Should use & as separator when proxy_base has query, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_empty_playlist() {
|
|
let m3u8 = "#EXTM3U\n#EXT-X-VERSION:3\n";
|
|
let rewritten =
|
|
rewrite_m3u8(m3u8, "https://cdn.example.com/master.m3u8", "/proxy/stream").unwrap();
|
|
// Should not crash; output should still contain the header tags
|
|
assert!(rewritten.contains("#EXTM3U"));
|
|
assert!(rewritten.contains("#EXT-X-VERSION:3"));
|
|
// No url= parameters since there are no segments
|
|
assert!(!rewritten.contains("url="));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_max_urls_truncated() {
|
|
// Build a live playlist (no #EXT-X-ENDLIST) with 1002 segments (exceeds MAX_M3U8_URLS = 1000)
|
|
// Live streams should NOT get #EXT-X-ENDLIST when truncated
|
|
let mut m3u8 = String::from("#EXTM3U\n");
|
|
for i in 0..1002 {
|
|
writeln!(m3u8, "seg{i}.ts").unwrap();
|
|
}
|
|
let rewritten = rewrite_m3u8(
|
|
&m3u8,
|
|
"https://cdn.example.com/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// Live stream should NOT contain #EXT-X-ENDLIST after truncation
|
|
assert!(
|
|
!rewritten.contains("#EXT-X-ENDLIST"),
|
|
"Live playlist (no EXT-X-ENDLIST) should be truncated WITHOUT EXT-X-ENDLIST"
|
|
);
|
|
// Count how many url= references there are - should be exactly 1000
|
|
let url_count = rewritten.matches("url=").count();
|
|
assert_eq!(
|
|
url_count, 1000,
|
|
"Should have exactly 1000 rewritten URLs, got {url_count}"
|
|
);
|
|
}
|
|
|
|
// percent_encode
|
|
|
|
#[test]
|
|
fn test_percent_encode_special_chars() {
|
|
let encoded = percent_encode("https://example.com/path?key=value&foo=bar");
|
|
assert!(encoded.contains("%3A")); // ':'
|
|
assert!(encoded.contains("%2F")); // '/'
|
|
assert!(encoded.contains("%3F")); // '?'
|
|
assert!(encoded.contains("%3D")); // '='
|
|
assert!(encoded.contains("%26")); // '&'
|
|
}
|
|
|
|
#[test]
|
|
fn test_percent_encode_already_safe() {
|
|
// NON_ALPHANUMERIC encodes everything except A-Z, a-z, 0-9.
|
|
// Characters like -, _, ., ~ ARE encoded by this encode set.
|
|
let input = "abcXYZ012";
|
|
assert_eq!(percent_encode(input), input);
|
|
// Verify that special unreserved chars are percent-encoded
|
|
assert_eq!(percent_encode("-"), "%2D");
|
|
assert_eq!(percent_encode("_"), "%5F");
|
|
assert_eq!(percent_encode("."), "%2E");
|
|
assert_eq!(percent_encode("~"), "%7E");
|
|
}
|
|
|
|
// SSRF ACL IP checks (formerly validate_proxy_url_static)
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_private_ip_blocked() {
|
|
use std::net::IpAddr;
|
|
let blocked: Vec<IpAddr> = vec![
|
|
"192.168.1.1".parse().unwrap(),
|
|
"10.0.0.1".parse().unwrap(),
|
|
"172.16.0.1".parse().unwrap(),
|
|
];
|
|
for ip in &blocked {
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
|
|
"strict SSRF policy should block {ip}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_public_ip_allowed() {
|
|
use std::net::IpAddr;
|
|
let allowed: Vec<IpAddr> = vec!["1.1.1.1".parse().unwrap(), "8.8.8.8".parse().unwrap()];
|
|
for ip in &allowed {
|
|
assert!(
|
|
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
|
|
"IP {ip} should be allowed"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_loopback_blocked() {
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block loopback"
|
|
);
|
|
}
|
|
|
|
// percent_encode - unicode
|
|
|
|
#[test]
|
|
fn test_percent_encode_unicode() {
|
|
let encoded = percent_encode("hello\u{00e9}"); // hello + e-acute
|
|
assert!(encoded.starts_with("hello"));
|
|
// The e-acute (U+00E9) should be percent-encoded as %C3%A9 (UTF-8)
|
|
assert!(encoded.contains("%C3%A9"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_percent_encode_cjk() {
|
|
let encoded = percent_encode("\u{4e16}\u{754c}"); // Chinese: "world"
|
|
assert!(!encoded.contains('\u{4e16}'));
|
|
assert!(encoded.contains('%'));
|
|
}
|
|
|
|
// SSRF ACL - additional edge cases
|
|
|
|
#[test]
|
|
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::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block link-local addresses"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_cgnat_blocked() {
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "100.64.0.1".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block CGNAT addresses"
|
|
);
|
|
}
|
|
|
|
// PX4: M3U8 EXT-X-MAP URI rewriting
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_ext_x_map_uri_rewritten() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-VERSION:7\n",
|
|
"#EXT-X-MAP:URI=\"init.mp4\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0.m4s\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg1.m4s\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/stream/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// The URI in EXT-X-MAP should be rewritten to a proxied URL
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"EXT-X-MAP URI should be rewritten, got:\n{rewritten}"
|
|
);
|
|
// The init segment URL should be made absolute (relative to source URL)
|
|
assert!(
|
|
rewritten.contains("cdn%2Eexample%2Ecom"),
|
|
"init.mp4 should be resolved to absolute URL with CDN host, got:\n{rewritten}"
|
|
);
|
|
// Verify the init segment path includes the correct directory
|
|
assert!(
|
|
rewritten.contains("init%2Emp4"),
|
|
"Init segment filename should be encoded, got:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_ext_x_map_absolute_uri() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-MAP:URI=\"https://cdn.other.com/init.mp4\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0.m4s\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// Absolute URI in EXT-X-MAP should be proxied as-is
|
|
assert!(
|
|
rewritten
|
|
.contains("URI=\"/proxy/stream?url=https%3A%2F%2Fcdn%2Eother%2Ecom%2Finit%2Emp4\""),
|
|
"Absolute EXT-X-MAP URI should be proxied, got:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_ext_x_map_with_byterange() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1024@0\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0.m4s\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
// The URI should be rewritten and BYTERANGE should be preserved
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"EXT-X-MAP URI with BYTERANGE should be rewritten, got:\n{rewritten}"
|
|
);
|
|
assert!(
|
|
rewritten.contains("BYTERANGE=\"1024@0\""),
|
|
"BYTERANGE should be preserved, got:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_m3u8_variant_playlist_with_ext_x_map() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-VERSION:7\n",
|
|
"#EXT-X-TARGETDURATION:6\n",
|
|
"#EXT-X-MEDIA-SEQUENCE:0\n",
|
|
"#EXT-X-MAP:URI=\"init_720p.mp4\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0_720p.m4s\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg1_720p.m4s\n",
|
|
"#EXT-X-ENDLIST\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/720p/playlist.m3u8",
|
|
"/proxy/stream?quality=720",
|
|
)
|
|
.unwrap();
|
|
// EXT-X-MAP should use & since proxy_base has query
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?quality=720&url="),
|
|
"EXT-X-MAP URI should use & separator when proxy_base has query, got:\n{rewritten}"
|
|
);
|
|
// Segment URLs should also use &
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?quality=720&url="),
|
|
"Segment URLs should use & separator, got:\n{rewritten}"
|
|
);
|
|
// Verify init_720p.mp4 is resolved correctly
|
|
assert!(
|
|
rewritten.contains("init%5F720p%2Emp4"),
|
|
"Init segment should be percent-encoded, got:\n{rewritten}"
|
|
);
|
|
// EXT-X-ENDLIST should be preserved
|
|
assert!(
|
|
rewritten.contains("#EXT-X-ENDLIST"),
|
|
"EXT-X-ENDLIST should be preserved, got:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
// PX5: Referer query string test
|
|
|
|
#[test]
|
|
fn test_referer_not_constructed_from_url_with_query() {
|
|
let headers = build_and_get_headers(
|
|
"https://cdn.example.com/path/video.mp4?token=abc&expires=123",
|
|
&HashMap::new(),
|
|
);
|
|
assert!(
|
|
headers.get("referer").is_none(),
|
|
"Referer should not be derived from signed media URLs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_referer_with_custom_referer_header() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert(
|
|
"Referer".to_string(),
|
|
"https://www.bilibili.com".to_string(),
|
|
);
|
|
let headers = build_and_get_headers("https://cdn.bilibili.com/seg1.ts?token=abc", &provider);
|
|
let referer = headers.get("referer").expect("Custom Referer should exist");
|
|
assert_eq!(
|
|
referer.to_str().unwrap(),
|
|
"https://www.bilibili.com",
|
|
"Custom Referer should override default"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_referer_not_constructed_from_url_with_port() {
|
|
let headers = build_and_get_headers(
|
|
"https://cdn.example.com:8443/path/video.mp4",
|
|
&HashMap::new(),
|
|
);
|
|
assert!(
|
|
headers.get("referer").is_none(),
|
|
"Referer should only be sent when provider headers include it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_headers_forwarded_with_referer() {
|
|
let mut provider = HashMap::new();
|
|
provider.insert("X-Custom-Header".to_string(), "custom-value".to_string());
|
|
provider.insert(
|
|
"Referer".to_string(),
|
|
"https://custom.referer.com/page".to_string(),
|
|
);
|
|
let headers =
|
|
build_and_get_headers("https://cdn.example.com/video.mp4?query=string", &provider);
|
|
// Both custom headers should be present
|
|
assert_eq!(
|
|
headers.get("x-custom-header").map(|v| v.to_str().unwrap()),
|
|
Some("custom-value")
|
|
);
|
|
assert_eq!(
|
|
headers.get("referer").map(|v| v.to_str().unwrap()),
|
|
Some("https://custom.referer.com/page")
|
|
);
|
|
}
|
|
|
|
// PX3: Body size scan() combinator (unit-level)
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_ipv6_loopback_blocked() {
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "::1".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block IPv6 loopback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_ipv6_unspecified_blocked() {
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "::".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block IPv6 unspecified"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssrf_acl_ipv6_public_allowed() {
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "2606:4700:4700::1111".parse().unwrap();
|
|
assert!(
|
|
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"Public IPv6 should be allowed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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::strict_policy().is_ip_blocked(&ip),
|
|
"strict SSRF policy should block cloud metadata IPs"
|
|
);
|
|
}
|
|
|
|
// M3U8 SSRF Security Tests
|
|
|
|
/// Test that directory traversal attacks in M3U8 segment URLs are properly
|
|
/// resolved and normalized. A segment like `../../../etc/passwd` should
|
|
/// resolve to a URL that still targets the same host, not escape to local files.
|
|
#[test]
|
|
fn test_m3u8_ssrf_path_traversal_attack() {
|
|
// Attacker-controlled M3U8 with directory traversal
|
|
let m3u8 = "#EXTM3U\n#EXT-X-VERSION:3\n../../../etc/passwd\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/stream/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The rewritten URL should still point to cdn.example.com
|
|
// Directory traversal should be normalized by url::Url::join
|
|
assert!(
|
|
rewritten.contains("cdn%2Eexample%2Ecom"),
|
|
"Path traversal should resolve to same host, got: {rewritten}"
|
|
);
|
|
|
|
// The path should be normalized - url::Url::join normalizes `..` sequences
|
|
// The resulting path should NOT contain raw `..` sequences
|
|
assert!(
|
|
!rewritten.contains(".."),
|
|
"Path should be normalized without .. sequences, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that M3U8 segment URLs pointing to private IP addresses are
|
|
/// NOT directly embedded in the rewritten output. The proxy URL should
|
|
/// point to our proxy endpoint, and the proxy will validate the actual
|
|
/// target URL when fetching.
|
|
#[test]
|
|
fn test_m3u8_ssrf_private_ip_in_segment() {
|
|
// M3U8 with segment pointing to internal server
|
|
let m3u8 = "#EXTM3U\nhttp://192.168.1.1/internal/secret.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The segment URL should be encoded as a parameter to our proxy
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?url="),
|
|
"Segment should be proxied, got: {rewritten}"
|
|
);
|
|
|
|
// The private IP should be percent-encoded in the url parameter
|
|
assert!(
|
|
rewritten.contains("192%2E168%2E1%2E1"),
|
|
"Private IP should be encoded in proxy URL, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that M3U8 with EXT-X-KEY URI pointing to localhost is properly
|
|
/// rewritten through the proxy. The proxy endpoint will validate the URL
|
|
/// when the client fetches the key.
|
|
#[test]
|
|
fn test_m3u8_ssrf_localhost_in_key_uri() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"http://localhost/key.bin\"\n",
|
|
"seg1.ts\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The key URI should be rewritten to go through our proxy
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"Key URI should be proxied, got: {rewritten}"
|
|
);
|
|
|
|
// The localhost URL should be encoded in the parameter
|
|
assert!(
|
|
rewritten.contains("localhost"),
|
|
"Localhost should be in the encoded URL parameter, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that control characters and newlines in M3U8 content are handled
|
|
/// safely. Malicious M3U8 files should not inject extra lines or headers.
|
|
#[test]
|
|
fn test_m3u8_ssrf_newline_injection() {
|
|
// M3U8 with embedded newline that might try to inject a new segment line
|
|
// Note: The rewrite_m3u8 function iterates over lines(), so embedded \n
|
|
// within a line won't create new lines in output
|
|
let m3u8 = "#EXTM3U\n#EXT-X-VERSION:3\nseg1.ts\n#EXTINF:10,\nseg2.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Verify normal processing - should have exactly 2 segment URLs
|
|
let url_count = rewritten.matches("/proxy/stream?url=").count();
|
|
assert_eq!(
|
|
url_count, 2,
|
|
"Should have exactly 2 segment URLs, got {url_count}: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that URLs with control characters are handled safely.
|
|
#[test]
|
|
fn test_m3u8_ssrf_control_characters() {
|
|
// URL with embedded null byte (should be percent-encoded or stripped)
|
|
let m3u8 = "#EXTM3U\nseg\u{0000}1.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The null byte should be encoded (percent_encode handles UTF-8)
|
|
// or the URL resolution should handle it gracefully
|
|
// The key test is: no crash, output is valid
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?url="),
|
|
"Should still produce proxy URLs, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that extremely long paths with many `..` segments don't escape
|
|
/// to unexpected locations.
|
|
#[test]
|
|
fn test_m3u8_ssrf_deep_traversal() {
|
|
let m3u8 = "#EXTM3U\n../../../../../../../../../../../../etc/passwd\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/stream/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Path should be normalized to root of the host
|
|
assert!(
|
|
rewritten.contains("cdn%2Eexample%2Ecom"),
|
|
"Should resolve to cdn.example.com, got: {rewritten}"
|
|
);
|
|
|
|
// The path should NOT contain raw `..` - url::Url::join normalizes them
|
|
assert!(
|
|
!rewritten.contains(".."),
|
|
"Path traversal should be normalized, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that the `rewrite_m3u8` function properly handles EXT-X-MAP with
|
|
/// a URI that attempts SSRF.
|
|
#[test]
|
|
fn test_m3u8_ssrf_ext_x_map_internal_uri() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-MAP:URI=\"http://169.254.169.254/latest/meta-data/\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0.m4s\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The metadata IP should be proxied through our endpoint
|
|
assert!(
|
|
rewritten.contains("URI=\"/proxy/stream?url="),
|
|
"EXT-X-MAP URI should be proxied, got: {rewritten}"
|
|
);
|
|
|
|
// The AWS metadata IP should be in the encoded URL
|
|
assert!(
|
|
rewritten.contains("169%2E254%2E169%2E254"),
|
|
"Metadata IP should be encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that protocol-relative URLs are handled correctly
|
|
#[test]
|
|
fn test_m3u8_ssrf_protocol_relative() {
|
|
// Protocol-relative URL could potentially switch to file://
|
|
// but url::Url::join handles this correctly for http/https bases
|
|
let m3u8 = "#EXTM3U\n//attacker.com/malicious.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Protocol-relative URL should inherit the scheme from base
|
|
assert!(
|
|
rewritten.contains("attacker%2Ecom"),
|
|
"Protocol-relative URL should be resolved, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
// M3U8 SSRF End-to-End Validation Tests
|
|
|
|
/// Test that malicious URLs in M3U8 are rewritten through the proxy.
|
|
/// 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";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The file:// URL should be encoded in the proxy URL
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?url="),
|
|
"URL should be rewritten to proxy, got: {rewritten}"
|
|
);
|
|
|
|
// Extract the encoded URL and decode it
|
|
// The encoded URL contains "file%3A%2F%2F" which is "file://"
|
|
assert!(
|
|
rewritten.contains("file%3A%2F%2F"),
|
|
"file:// scheme should be encoded in proxy URL, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that private IPs are blocked by the SSRF ACL.
|
|
#[test]
|
|
fn test_m3u8_ssrf_private_ip_blocked_by_acl() {
|
|
use std::net::IpAddr;
|
|
let blocked: Vec<IpAddr> = vec![
|
|
"192.168.1.1".parse().unwrap(),
|
|
"10.0.0.1".parse().unwrap(),
|
|
"172.16.0.1".parse().unwrap(),
|
|
"127.0.0.1".parse().unwrap(),
|
|
"169.254.169.254".parse().unwrap(),
|
|
];
|
|
|
|
for ip in &blocked {
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
|
|
"strict SSRF policy should block private/internal IP {ip}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test that public IPs are allowed by the SSRF ACL.
|
|
#[test]
|
|
fn test_m3u8_ssrf_public_ip_allowed_by_acl() {
|
|
use std::net::IpAddr;
|
|
let allowed: Vec<IpAddr> = vec!["8.8.8.8".parse().unwrap(), "1.1.1.1".parse().unwrap()];
|
|
|
|
for ip in &allowed {
|
|
assert!(
|
|
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(ip),
|
|
"Public IP {ip} should be allowed by SSRF ACL"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test that URLs with special characters are safely encoded
|
|
#[test]
|
|
fn test_m3u8_ssrf_special_chars_encoded() {
|
|
// URL with special characters that might be used in injection attacks
|
|
let m3u8 = "#EXTM3U\nseg.ts?token=abc&redirect=http://evil.com\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The entire URL including special chars should be encoded
|
|
// & should become %26, : should become %3A, etc.
|
|
assert!(
|
|
rewritten.contains("%3A"),
|
|
"Colon should be encoded, got: {rewritten}"
|
|
);
|
|
assert!(
|
|
rewritten.contains("%26"),
|
|
"Ampersand should be encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that backslash and other potentially dangerous characters are handled
|
|
#[test]
|
|
fn test_m3u8_ssrf_backslash_handling() {
|
|
// Backslash might be used to bypass path normalization on Windows
|
|
let m3u8 = "#EXTM3U\n..\\..\\..\\etc\\passwd\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Backslash should be encoded as %5C
|
|
// The URL resolution should handle it gracefully
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?url="),
|
|
"Should produce proxy URL, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that encoded path traversal is handled
|
|
#[test]
|
|
fn test_m3u8_ssrf_encoded_traversal() {
|
|
// URL-encoded path traversal: %2e%2e%2f = ../
|
|
let m3u8 = "#EXTM3U\n%2e%2e%2f%2e%2e%2fetc/passwd\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/stream/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The double-encoding should result in a valid URL
|
|
// url::Url::join will treat %2e%2e%2f as literal characters, not as ../
|
|
// This is actually safe because the URL will be percent-encoded again
|
|
assert!(
|
|
rewritten.contains("/proxy/stream?url="),
|
|
"Should produce proxy URL, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
// M3U8 Double Encoding Bug Tests
|
|
|
|
/// Test that already-encoded URLs are not double-encoded from %20 to %2520.
|
|
#[test]
|
|
fn test_m3u8_no_double_encode_space() {
|
|
// URL with already-encoded space (%20)
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/path%20with%20spaces/seg.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// The %20 should NOT become %2520 (double-encoded)
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Space should NOT be double-encoded (%%2520), got: {rewritten}"
|
|
);
|
|
|
|
// Should still contain a single %20 for the space
|
|
assert!(
|
|
rewritten.contains("%20"),
|
|
"Encoded space should remain as %20, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that already-encoded Unicode characters are not double-encoded
|
|
#[test]
|
|
fn test_m3u8_no_double_encode_cjk() {
|
|
// URL with already-encoded non-ASCII characters
|
|
// %C3%A9 = é
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/%C3%A9/seg.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Should NOT double-encode: %E4 should not become %25E4
|
|
assert!(
|
|
!rewritten.contains("%25E4"),
|
|
"CJK characters should NOT be double-encoded, got: {rewritten}"
|
|
);
|
|
assert!(
|
|
!rewritten.contains("%25B8"),
|
|
"CJK characters should NOT be double-encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that already-encoded special chars in query string are not double-encoded
|
|
#[test]
|
|
fn test_m3u8_no_double_encode_query_params() {
|
|
// URL with already-encoded query parameters
|
|
// ?key=value%20with%20spaces&foo=bar%26baz
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/seg.ts?key=value%20with%20spaces\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// %20 should NOT become %2520
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Query param space should NOT be double-encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that mixed encoded and unencoded content works correctly
|
|
#[test]
|
|
fn test_m3u8_mixed_encoding() {
|
|
// URL with some encoded chars and some raw special chars
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/path%20space/file name.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Already-encoded %20 should stay as %20 (not %2520)
|
|
// Raw space should be encoded as %20
|
|
// The result should be valid and not have double-encoding
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Should not have double-encoded spaces, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test percent_encode handles already-encoded input correctly
|
|
#[test]
|
|
fn test_percent_encode_already_encoded() {
|
|
// %20 is an encoded space
|
|
let encoded = percent_encode("%20");
|
|
// Should NOT become %2520
|
|
assert_ne!(
|
|
encoded, "%2520",
|
|
"Already-encoded %20 should not be double-encoded"
|
|
);
|
|
// It should stay as %20
|
|
assert_eq!(encoded, "%20", "Already-encoded %20 should remain %20");
|
|
}
|
|
|
|
/// Test percent_encode with complex already-encoded URL
|
|
#[test]
|
|
fn test_percent_encode_complex_encoded_url() {
|
|
// A URL that's already properly encoded
|
|
let url = "https://example.com/path%20with%20spaces/file%2Bname.ts?key=value%26other%3Dval";
|
|
let encoded = percent_encode(url);
|
|
|
|
// Should not double-encode any % signs
|
|
assert!(
|
|
!encoded.contains("%25"),
|
|
"Should not have any double-encoded percent signs, got: {encoded}"
|
|
);
|
|
}
|
|
|
|
/// Test that EXT-X-KEY URI with already-encoded content is not double-encoded
|
|
#[test]
|
|
fn test_m3u8_ext_x_key_no_double_encode() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"https://cdn.example.com/key%20file.bin\"\n",
|
|
"seg1.ts\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// %20 in the key URI should NOT become %2520
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Key URI should not be double-encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that EXT-X-MAP URI with already-encoded content is not double-encoded
|
|
#[test]
|
|
fn test_m3u8_ext_x_map_no_double_encode() {
|
|
let m3u8 = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-MAP:URI=\"https://cdn.example.com/init%20file.mp4\"\n",
|
|
"#EXTINF:6.006,\n",
|
|
"seg0.m4s\n",
|
|
);
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// %20 in the map URI should NOT become %2520
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Map URI should not be double-encoded, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test handling of URL with plus sign (which may or may not be encoded)
|
|
#[test]
|
|
fn test_m3u8_plus_sign_handling() {
|
|
// + in URL can mean space (in query) or literal +
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/file+name.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// + should be encoded as %2B (since we encode non-alphanumeric)
|
|
// This is correct behavior for path segments
|
|
assert!(
|
|
rewritten.contains("%2B") || rewritten.contains('+'),
|
|
"Plus sign handling, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
/// Test that a fully unencoded URL with special chars works correctly
|
|
#[test]
|
|
fn test_m3u8_raw_special_chars_encoded_once() {
|
|
// URL with raw special characters that need encoding
|
|
let m3u8 = "#EXTM3U\nhttps://cdn.example.com/path with spaces/file.ts\n";
|
|
let rewritten = rewrite_m3u8(
|
|
m3u8,
|
|
"https://cdn.example.com/hls/master.m3u8",
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
|
|
// Space should be encoded (as %20, not as +)
|
|
assert!(
|
|
rewritten.contains("%20"),
|
|
"Raw space should be encoded, got: {rewritten}"
|
|
);
|
|
|
|
// Should NOT be double-encoded
|
|
assert!(
|
|
!rewritten.contains("%2520"),
|
|
"Should not be double-encoded, got: {rewritten}"
|
|
);
|
|
}
|