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