Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion internal/offline_download/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions internal/offline_download/tool/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +47 to +49
Comment on lines +47 to +49

// check storage
storage, dstDirActualPath, err := op.GetStorageAndActualPath(args.DstDirPath)
if err != nil {
Expand Down
91 changes: 91 additions & 0 deletions internal/offline_download/tool/metadata_url.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +50 to +54
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
}
Comment on lines +76 to +79
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
}
74 changes: 74 additions & 0 deletions internal/offline_download/tool/metadata_url_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 9 additions & 1 deletion internal/offline_download/transmission/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}
Expand Down
Loading