//! 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, ) -> 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 = 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 = 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 = 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 = 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}" ); }