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.
synctv/synctv-proxy/tests/proxy_unit_tests.rs

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}"
);
}