feat: add proxy target validation package

This commit is contained in:
2026-06-26 23:56:20 +02:00
parent ff4998f3f1
commit f8e3f89e42
2 changed files with 193 additions and 0 deletions

View 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()
}

View 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
}