diff --git a/internal/offline_download/http/client.go b/internal/offline_download/http/client.go index 5dcb6d590..77093aa18 100644 --- a/internal/offline_download/http/client.go +++ b/internal/offline_download/http/client.go @@ -50,6 +50,10 @@ func (s SimpleHttp) Status(task *tool.DownloadTask) (*tool.Status, error) { } func (s SimpleHttp) Run(task *tool.DownloadTask) error { + if err := tool.ValidateOfflineDownloadURL(task.Ctx(), task.Url); err != nil { + return err + } + streamPut := task.DeletePolicy == tool.UploadDownloadStream method := http.MethodGet if streamPut { @@ -63,7 +67,8 @@ func (s SimpleHttp) Run(task *tool.DownloadTask) error { if streamPut { req.Header.Set("Range", "bytes=0-") } - resp, err := s.client.Do(req) + client := tool.NewOfflineDownloadHTTPClient(s.client) + resp, err := client.Do(req) if err != nil { return err } diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go index 0f574571e..a30f33d47 100644 --- a/internal/offline_download/tool/add.go +++ b/internal/offline_download/tool/add.go @@ -44,6 +44,10 @@ type AddURLArgs struct { } func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskExtensionInfo, error) { + if err := ValidateOfflineDownloadURL(ctx, args.URL); err != nil { + return nil, err + } + // check storage storage, dstDirActualPath, err := op.GetStorageAndActualPath(args.DstDirPath) if err != nil { diff --git a/internal/offline_download/tool/metadata_url.go b/internal/offline_download/tool/metadata_url.go new file mode 100644 index 000000000..5d76a6dbd --- /dev/null +++ b/internal/offline_download/tool/metadata_url.go @@ -0,0 +1,91 @@ +package tool + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strings" +) + +var ( + ErrCloudMetadataEndpoint = errors.New("access to cloud metadata endpoint is not allowed") + lookupIPAddr = net.DefaultResolver.LookupIPAddr +) + +func ValidateOfflineDownloadURL(ctx context.Context, rawURL string) error { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + u, err := url.Parse(rawURL) + if err == nil && u.Host != "" { + return validateOfflineDownloadHost(ctx, u.Hostname()) + } + + if err == nil && u.Scheme != "" { + return nil + } + + // Keep scheme-less URLs compatible: only reject direct metadata IP forms here, + // instead of treating any leading path segment as a hostname and doing DNS. + host := strings.Trim(rawURL, "[]") + host, _, _ = strings.Cut(host, "/") + host, _, _ = strings.Cut(host, "?") + host, _, _ = strings.Cut(host, "#") + if splitHost, _, err := net.SplitHostPort(host); err == nil { + host = splitHost + } + if ip := net.ParseIP(host); isCloudMetadataIP(ip) { + return ErrCloudMetadataEndpoint + } + return nil +} + +func NewOfflineDownloadHTTPClient(base http.Client) *http.Client { + client := base + previousCheckRedirect := client.CheckRedirect + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if err := ValidateOfflineDownloadURL(req.Context(), req.URL.String()); err != nil { + return err + } + if previousCheckRedirect != nil { + return previousCheckRedirect(req, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + } + return &client +} + +func validateOfflineDownloadHost(ctx context.Context, host string) error { + if ip := net.ParseIP(host); ip != nil { + if isCloudMetadataIP(ip) { + return ErrCloudMetadataEndpoint + } + return nil + } + + addrs, err := lookupIPAddr(ctx, host) + if err != nil { + return err + } + for _, addr := range addrs { + if isCloudMetadataIP(addr.IP) { + return ErrCloudMetadataEndpoint + } + } + return nil +} + +func isCloudMetadataIP(ip net.IP) bool { + ip = ip.To4() + return ip != nil && ip[0] == 169 && ip[1] == 254 && ip[2] == 169 && ip[3] == 254 +} diff --git a/internal/offline_download/tool/metadata_url_test.go b/internal/offline_download/tool/metadata_url_test.go new file mode 100644 index 000000000..0bd56adc4 --- /dev/null +++ b/internal/offline_download/tool/metadata_url_test.go @@ -0,0 +1,74 @@ +package tool + +import ( + "context" + "errors" + "net" + "net/http" + "testing" +) + +func TestValidateOfflineDownloadURLRejectsCloudMetadataIP(t *testing.T) { + err := ValidateOfflineDownloadURL(context.Background(), "http://169.254.169.254/") + if !errors.Is(err, ErrCloudMetadataEndpoint) { + t.Fatalf("expected cloud metadata error, got %v", err) + } +} + +func TestValidateOfflineDownloadURLRejectsCloudMetadataIPWithoutScheme(t *testing.T) { + err := ValidateOfflineDownloadURL(context.Background(), "169.254.169.254") + if !errors.Is(err, ErrCloudMetadataEndpoint) { + t.Fatalf("expected cloud metadata error, got %v", err) + } +} + +func TestValidateOfflineDownloadURLRejectsCloudMetadataIPWithPort(t *testing.T) { + err := ValidateOfflineDownloadURL(context.Background(), "http://169.254.169.254:80/") + if !errors.Is(err, ErrCloudMetadataEndpoint) { + t.Fatalf("expected cloud metadata error, got %v", err) + } +} + +func TestValidateOfflineDownloadURLAllowsPublicURL(t *testing.T) { + err := ValidateOfflineDownloadURL(context.Background(), "http://8.8.8.8/") + if err != nil { + t.Fatalf("expected public URL to be allowed, got %v", err) + } +} + +func TestValidateOfflineDownloadURLAllowsPrivateURL(t *testing.T) { + err := ValidateOfflineDownloadURL(context.Background(), "http://192.168.1.10:8080/") + if err != nil { + t.Fatalf("expected private URL to be allowed, got %v", err) + } +} + +func TestValidateOfflineDownloadURLRejectsDomainResolvingToCloudMetadataIP(t *testing.T) { + previousLookup := lookupIPAddr + lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { + if host != "metadata.example.test" { + t.Fatalf("unexpected host lookup: %s", host) + } + return []net.IPAddr{{IP: net.ParseIP("169.254.169.254")}}, nil + } + defer func() { + lookupIPAddr = previousLookup + }() + + err := ValidateOfflineDownloadURL(context.Background(), "http://metadata.example.test/") + if !errors.Is(err, ErrCloudMetadataEndpoint) { + t.Fatalf("expected cloud metadata error, got %v", err) + } +} + +func TestOfflineDownloadHTTPClientRejectsRedirectToCloudMetadataIP(t *testing.T) { + client := NewOfflineDownloadHTTPClient(http.Client{}) + req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data/", nil) + if err != nil { + t.Fatalf("failed to build redirect request: %v", err) + } + err = client.CheckRedirect(req, nil) + if !errors.Is(err, ErrCloudMetadataEndpoint) { + t.Fatalf("expected cloud metadata error, got %v", err) + } +} diff --git a/internal/offline_download/transmission/client.go b/internal/offline_download/transmission/client.go index f86390fb8..100e7af5e 100644 --- a/internal/offline_download/transmission/client.go +++ b/internal/offline_download/transmission/client.go @@ -74,6 +74,10 @@ func (t *Transmission) IsReady() bool { } func (t *Transmission) AddURL(args *tool.AddUrlArgs) (string, error) { + if err := tool.ValidateOfflineDownloadURL(context.Background(), args.Url); err != nil { + return "", err + } + endpoint, err := url.Parse(args.Url) if err != nil { return "", errors.Wrap(err, "failed to parse transmission uri") @@ -84,7 +88,11 @@ func (t *Transmission) AddURL(args *tool.AddUrlArgs) (string, error) { } // http url for .torrent file if endpoint.Scheme == "http" || endpoint.Scheme == "https" { - resp, err := http.Get(args.Url) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, args.Url, nil) + if err != nil { + return "", err + } + resp, err := tool.NewOfflineDownloadHTTPClient(http.Client{}).Do(req) if err != nil { return "", errors.Wrap(err, "failed to get .torrent file") }