feat: add proxy target validation package
This commit is contained in:
125
internal/playback/proxytarget/target.go
Normal file
125
internal/playback/proxytarget/target.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
68
internal/playback/proxytarget/target_test.go
Normal file
68
internal/playback/proxytarget/target_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user