diff --git a/internal/playback/proxytarget/target.go b/internal/playback/proxytarget/target.go new file mode 100644 index 00000000..840b4390 --- /dev/null +++ b/internal/playback/proxytarget/target.go @@ -0,0 +1,125 @@ +package proxytarget + +import ( + "context" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" +) + +type ipResolver interface { + LookupIPAddr(context.Context, string) ([]net.IPAddr, error) +} + +func Validate(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse proxy target: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("proxy target scheme must be http or https") + } + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("proxy target host is required") + } + if isLocalhostName(host) { + return fmt.Errorf("proxy target host is local") + } + if addr, err := netip.ParseAddr(host); err == nil && !isAllowedAddr(addr) { + return fmt.Errorf("proxy target address is not allowed") + } + return nil +} + +func NewClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: newTransport(10*time.Second, 10*time.Second, 30*time.Second), + Timeout: timeout, + CheckRedirect: checkRedirect, + } +} + +func NewStreamingClient() *http.Client { + return &http.Client{ + Transport: newTransport(10*time.Second, 10*time.Second, 15*time.Second), + CheckRedirect: checkRedirect, + } +} + +func checkRedirect(req *http.Request, _ []*http.Request) error { + return Validate(req.URL.String()) +} + +func newTransport(dialTimeout, tlsTimeout, headerTimeout time.Duration) *http.Transport { + dialer := &net.Dialer{Timeout: dialTimeout} + return &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: tlsTimeout, + ResponseHeaderTimeout: headerTimeout, + ExpectContinueTimeout: 1 * time.Second, + DialContext: validatingDialContext(dialer, net.DefaultResolver), + } +} + +func validatingDialContext(dialer *net.Dialer, resolver ipResolver) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network string, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse proxy target address: %w", err) + } + if isLocalhostName(host) { + return nil, fmt.Errorf("proxy target host is local") + } + + if addr, err := netip.ParseAddr(host); err == nil { + if !isAllowedAddr(addr) { + return nil, fmt.Errorf("proxy target address is not allowed") + } + return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port)) + } + + ips, err := resolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve proxy target host: %w", err) + } + for _, ip := range ips { + addr, ok := addrFromIP(ip.IP) + if !ok || !isAllowedAddr(addr) { + continue + } + return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port)) + } + return nil, fmt.Errorf("proxy target resolved to no allowed addresses") + } +} + +func isLocalhostName(host string) bool { + lower := strings.TrimSuffix(strings.ToLower(host), ".") + return lower == "localhost" || strings.HasSuffix(lower, ".localhost") +} + +func addrFromIP(ip net.IP) (netip.Addr, bool) { + if v4 := ip.To4(); v4 != nil { + return netip.AddrFrom4([4]byte{v4[0], v4[1], v4[2], v4[3]}), true + } + if v6 := ip.To16(); v6 != nil { + return netip.AddrFrom16([16]byte{v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7], v6[8], v6[9], v6[10], v6[11], v6[12], v6[13], v6[14], v6[15]}), true + } + return netip.Addr{}, false +} + +func isAllowedAddr(addr netip.Addr) bool { + return addr.IsValid() && + addr.IsGlobalUnicast() && + !addr.IsLoopback() && + !addr.IsPrivate() && + !addr.IsLinkLocalUnicast() && + !addr.IsUnspecified() +} diff --git a/internal/playback/proxytarget/target_test.go b/internal/playback/proxytarget/target_test.go new file mode 100644 index 00000000..8a4dff9b --- /dev/null +++ b/internal/playback/proxytarget/target_test.go @@ -0,0 +1,68 @@ +package proxytarget + +import ( + "context" + "net" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestValidateAllowsHTTPSTargetWithHostname(t *testing.T) { + if err := Validate("https://cdn.example.test/video/segment.ts"); err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } +} + +func TestValidateRejectsUnsafeTargets(t *testing.T) { + tests := []string{ + "ftp://cdn.example.test/video.ts", + "https:///video.ts", + "http://localhost/video.ts", + "http://127.0.0.1/video.ts", + "http://[::ffff:127.0.0.1]/video.ts", + "http://169.254.169.254/video.ts", + "https://[::1]/subtitle.vtt", + } + + for _, targetURL := range tests { + t.Run(targetURL, func(t *testing.T) { + if err := Validate(targetURL); err == nil { + t.Fatal("Validate() error = nil, want error") + } + }) + } +} + +func TestCheckRedirectRejectsUnsafeLocation(t *testing.T) { + req := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1", Path: "/video.ts"}} + + if err := checkRedirect(req, nil); err == nil { + t.Fatal("checkRedirect() error = nil, want error") + } +} + +func TestValidatingDialContextRejectsPrivateResolvedAddress(t *testing.T) { + dial := validatingDialContext(&net.Dialer{Timeout: time.Millisecond}, fakeResolver{ + ips: []net.IPAddr{{IP: net.ParseIP("192.168.1.20")}}, + }) + + _, err := dial(context.Background(), "tcp", "cdn.example.test:443") + if err == nil { + t.Fatal("dial error = nil, want error") + } + if !strings.Contains(err.Error(), "no allowed addresses") { + t.Fatalf("dial error = %v, want no allowed addresses", err) + } +} + +type fakeResolver struct { + ips []net.IPAddr + err error +} + +func (r fakeResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) { + return r.ips, r.err +}