From aaebf317a78f2413d74afd66c21a1f3143711312 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Sat, 6 Jun 2026 10:54:11 +0800 Subject: [PATCH] feat(remote download): configurable SSRF check for target URL --- assets | 2 +- inventory/types/types.go | 20 ++ pkg/filemanager/workflows/remote_download.go | 33 +++ pkg/request/ssrf.go | 219 +++++++++++++++++++ pkg/request/ssrftest/ssrf_test.go | 163 ++++++++++++++ 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 pkg/request/ssrf.go create mode 100644 pkg/request/ssrftest/ssrf_test.go diff --git a/assets b/assets index 1678ce8a..bf9c1ff7 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 1678ce8a748123ffc078ef958c6e68523a999128 +Subproject commit bf9c1ff7c196c6e3759bacdbf496d5dc2e2d2c35 diff --git a/inventory/types/types.go b/inventory/types/types.go index 3f082681..8af1614c 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -121,6 +121,26 @@ type ( // 下载监控间隔 Interval int `json:"interval,omitempty"` WaitForSeeding bool `json:"wait_for_seeding,omitempty"` + // URLValidation controls SSRF policy applied to user-supplied URLs + // fetched by this node's downloader. nil means the secure default + // (validation on, no extra allowlist) — existing nodes upgraded in + // place stay protected without admin action. + URLValidation *URLValidationSetting `json:"url_validation,omitempty"` + } + + URLValidationSetting struct { + // Disabled turns the SSRF check off entirely on this node. Only set + // this when the downloader runs in a network segment that cannot + // reach any internal asset (e.g. dedicated egress namespace). + Disabled bool `json:"disabled,omitempty"` + // AllowedHosts is a list of hostnames or IP literals that bypass all + // checks. Exact, case-insensitive match against url.Hostname(). + AllowedHosts []string `json:"allowed_hosts,omitempty"` + // AllowedCIDRs is a list of CIDR blocks (IPv4 or IPv6) whose IPs are + // treated as safe even if they would otherwise be rejected (private, + // link-local, etc.). Use this to whitelist a LAN range like + // "192.168.10.0/24" so a local NAS can be fetched. + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` } DownloaderProvider string diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go index e614cbd1..4b78afc8 100644 --- a/pkg/filemanager/workflows/remote_download.go +++ b/pkg/filemanager/workflows/remote_download.go @@ -25,6 +25,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/queue" + "github.com/cloudreve/Cloudreve/v4/pkg/request" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/samber/lo" ) @@ -176,6 +177,20 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende user := inventory.UserFromContext(ctx) torrentUrl := m.state.SrcUri + + // SSRF policy is owned by the node that actually performs the fetch. + // Validate the user-supplied URL (m.state.SrcUri) against the assigned + // node's URLValidation policy, with the operator-configured site URL + // host(s) always added to the allowlist. We don't validate SrcFileUri + // resolution because that's a Cloudreve-internal entity URL pointing at + // the user's own torrent file. + if m.state.SrcUri != "" { + opt := buildSSRFOptions(ctx, dep, m.node.Settings(ctx)) + if err := request.ValidateExternalURL(ctx, m.state.SrcUri, opt); err != nil { + return task.StatusError, fmt.Errorf("url rejected: %s (%w)", err, queue.CriticalErr) + } + } + if m.state.SrcFileUri != "" { // Target is a torrent file uri, err := fs.NewUriFromString(m.state.SrcFileUri) @@ -210,6 +225,24 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende return task.StatusSuspending, nil } +// buildSSRFOptions composes the SSRF policy for a download: the assigned +// node's URLValidation settings, plus the operator-configured site URL hosts +// (always allowlisted so users can fetch files served by Cloudreve itself). +func buildSSRFOptions(ctx context.Context, dep dependency.Dep, node *types.NodeSetting) request.SSRFOptions { + opt := request.SSRFOptions{} + if node != nil && node.URLValidation != nil { + opt.Disabled = node.URLValidation.Disabled + opt.AllowedHosts = append(opt.AllowedHosts, node.URLValidation.AllowedHosts...) + opt.AllowedCIDRs = append(opt.AllowedCIDRs, node.URLValidation.AllowedCIDRs...) + } + for _, u := range dep.SettingProvider().AllSiteURLs(ctx) { + if h := u.Hostname(); h != "" { + opt.AllowedHosts = append(opt.AllowedHosts, h) + } + } + return opt +} + func (m *RemoteDownloadTask) monitor(ctx context.Context, dep dependency.Dep) (task.Status, error) { resumeAfter := time.Duration(m.node.Settings(ctx).Interval) * time.Second diff --git a/pkg/request/ssrf.go b/pkg/request/ssrf.go new file mode 100644 index 00000000..02c8d6dd --- /dev/null +++ b/pkg/request/ssrf.go @@ -0,0 +1,219 @@ +package request + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +// ErrUnsafeURL is returned when a user-supplied URL targets an address that +// should not be reachable by server-side fetchers (loopback, private, +// link-local, cloud metadata, multicast, etc.) or uses a non-allowed scheme. +var ErrUnsafeURL = errors.New("URL is not allowed") + +var downloadAllowedSchemes = map[string]struct{}{ + "http": {}, + "https": {}, + "ftp": {}, + "ftps": {}, + "sftp": {}, + "magnet": {}, +} + +// trackerAllowedSchemes are schemes valid inside magnet tr=/ws=/xs=/as= params +// that we recognize and validate. Anything else is treated as non-network +// (e.g. dht://) and skipped. +var trackerAllowedSchemes = map[string]struct{}{ + "http": {}, + "https": {}, + "udp": {}, + "ws": {}, + "wss": {}, +} + +var bannedHostnames = map[string]struct{}{ + "localhost": {}, + "localhost.localdomain": {}, + "ip6-localhost": {}, + "ip6-loopback": {}, +} + +// 100.64.0.0/10 (RFC 6598 CGNAT) is not flagged by net.IP.IsPrivate. +var cgnatNet = mustParseCIDR("100.64.0.0/10") + +// 169.254.169.254 (cloud instance metadata) is link-local and already covered +// by IsLinkLocalUnicast, but we keep an explicit check for clarity. +var cloudMetadataIP = net.ParseIP("169.254.169.254") + +func mustParseCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +// SSRFOptions configures ValidateExternalURL. +type SSRFOptions struct { + // Disabled short-circuits the entire check. Use when the operator has + // explicitly opted out (e.g. node-level URLValidation.Disabled). + Disabled bool + // AllowedHosts contains hostnames whose URLs bypass all subsequent checks + // (used to whitelist the operator-configured site URL host(s) and any + // operator-trusted internal hostnames). Compared case-insensitively to + // the URL's Hostname(). Port is ignored. + AllowedHosts []string + // AllowedCIDRs contains CIDR blocks whose IPs bypass the IP-class checks. + // Resolved IPs falling within any of these are treated as safe even if + // they would otherwise be rejected (private, link-local, ...). Malformed + // entries are ignored. Used to opt a LAN range back in. + AllowedCIDRs []string + // Resolver is used for DNS lookups; nil uses net.DefaultResolver. + Resolver *net.Resolver +} + +// ValidateExternalURL returns an error wrapping ErrUnsafeURL if the URL would +// reach an internal-only address when fetched by a server-side downloader. +// Magnet links have their tr=/ws=/xs=/as= parameters validated as URLs. +func ValidateExternalURL(ctx context.Context, raw string, opt SSRFOptions) error { + if opt.Disabled { + return nil + } + + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("empty URL: %w", ErrUnsafeURL) + } + + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse URL: %w", ErrUnsafeURL) + } + + scheme := strings.ToLower(u.Scheme) + if _, ok := downloadAllowedSchemes[scheme]; !ok { + return fmt.Errorf("scheme %q not allowed: %w", u.Scheme, ErrUnsafeURL) + } + + if scheme == "magnet" { + return validateMagnet(ctx, u, opt) + } + + return validateHost(ctx, u.Hostname(), opt) +} + +func validateMagnet(ctx context.Context, u *url.URL, opt SSRFOptions) error { + q := u.Query() + for _, key := range []string{"tr", "ws", "xs", "as"} { + for _, v := range q[key] { + sub, err := url.Parse(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("magnet %s=%q: %w", key, v, ErrUnsafeURL) + } + if sub.Hostname() == "" { + continue + } + if _, ok := trackerAllowedSchemes[strings.ToLower(sub.Scheme)]; !ok { + continue + } + if err := validateHost(ctx, sub.Hostname(), opt); err != nil { + return fmt.Errorf("magnet %s=%q: %w", key, v, err) + } + } + } + return nil +} + +func validateHost(ctx context.Context, host string, opt SSRFOptions) error { + host = strings.TrimSpace(host) + if host == "" { + return fmt.Errorf("empty host: %w", ErrUnsafeURL) + } + + for _, allowed := range opt.AllowedHosts { + if strings.EqualFold(strings.TrimSpace(allowed), host) { + return nil + } + } + + lowered := strings.ToLower(host) + if _, banned := bannedHostnames[lowered]; banned { + return fmt.Errorf("hostname %q is local: %w", host, ErrUnsafeURL) + } + if strings.HasSuffix(lowered, ".localhost") { + return fmt.Errorf("hostname %q is local: %w", host, ErrUnsafeURL) + } + + allowed := parseCIDRs(opt.AllowedCIDRs) + + if ip := net.ParseIP(host); ip != nil { + return checkIPWithAllowlist(ip, allowed) + } + + resolver := opt.Resolver + if resolver == nil { + resolver = net.DefaultResolver + } + addrs, err := resolver.LookupIPAddr(ctx, host) + if err != nil { + return fmt.Errorf("resolve %q: %w", host, ErrUnsafeURL) + } + if len(addrs) == 0 { + return fmt.Errorf("no addresses for %q: %w", host, ErrUnsafeURL) + } + for _, a := range addrs { + if err := checkIPWithAllowlist(a.IP, allowed); err != nil { + return err + } + } + return nil +} + +// parseCIDRs parses CIDR strings, silently dropping malformed entries — the +// admin gets validation feedback at config-save time, so noisy errors during a +// download are not useful. +func parseCIDRs(raw []string) []*net.IPNet { + out := make([]*net.IPNet, 0, len(raw)) + for _, r := range raw { + _, n, err := net.ParseCIDR(strings.TrimSpace(r)) + if err == nil { + out = append(out, n) + } + } + return out +} + +func checkIPWithAllowlist(ip net.IP, allowed []*net.IPNet) error { + for _, n := range allowed { + if n.Contains(ip) { + return nil + } + } + return checkIP(ip) +} + +func checkIP(ip net.IP) error { + if ip == nil { + return fmt.Errorf("invalid IP: %w", ErrUnsafeURL) + } + switch { + case ip.IsLoopback(): + return fmt.Errorf("loopback address %s: %w", ip, ErrUnsafeURL) + case ip.IsUnspecified(): + return fmt.Errorf("unspecified address %s: %w", ip, ErrUnsafeURL) + case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(): + return fmt.Errorf("link-local address %s: %w", ip, ErrUnsafeURL) + case ip.IsPrivate(): + return fmt.Errorf("private address %s: %w", ip, ErrUnsafeURL) + case ip.IsMulticast(): + return fmt.Errorf("multicast address %s: %w", ip, ErrUnsafeURL) + case ip.Equal(cloudMetadataIP): + return fmt.Errorf("cloud metadata address %s: %w", ip, ErrUnsafeURL) + case cgnatNet.Contains(ip): + return fmt.Errorf("CGNAT address %s: %w", ip, ErrUnsafeURL) + } + return nil +} diff --git a/pkg/request/ssrftest/ssrf_test.go b/pkg/request/ssrftest/ssrf_test.go new file mode 100644 index 00000000..995f5dd4 --- /dev/null +++ b/pkg/request/ssrftest/ssrf_test.go @@ -0,0 +1,163 @@ +// Package ssrftest contains tests for pkg/request SSRF validation. It lives +// in a separate directory so it can compile even when the parent package's +// pre-existing _test.go files do not. +package ssrftest + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/request" + "github.com/stretchr/testify/assert" +) + +func TestValidateExternalURL_Scheme(t *testing.T) { + ctx := context.Background() + cases := []string{ + "file:///etc/passwd", + "gopher://example.com/_GET", + "dict://127.0.0.1:11211/stat", + "ldap://x", + "javascript:alert(1)", + "", + } + for _, raw := range cases { + err := request.ValidateExternalURL(ctx, raw, request.SSRFOptions{}) + assert.ErrorIs(t, err, request.ErrUnsafeURL, "raw=%q", raw) + } +} + +func TestValidateExternalURL_LocalLiterals(t *testing.T) { + ctx := context.Background() + cases := []string{ + "http://localhost/x", + "http://LOCALHOST:8080/x", + "http://ip6-localhost/x", + "http://service.localhost/x", + "https://localhost.localdomain/x", + } + for _, raw := range cases { + err := request.ValidateExternalURL(ctx, raw, request.SSRFOptions{}) + assert.ErrorIs(t, err, request.ErrUnsafeURL, "raw=%q", raw) + } +} + +func TestValidateExternalURL_IPLiterals(t *testing.T) { + ctx := context.Background() + cases := []string{ + "http://127.0.0.1:7777/secret", + "http://[::1]:7780/v6secret", + "http://10.0.0.5/internal", + "http://172.16.5.5/internal", + "http://192.168.1.1/router", + "http://169.254.169.254/latest/meta", + "http://169.254.42.42/link-local", + "http://[fe80::1]/link-local-v6", + "http://[fc00::1]/ula", + "http://0.0.0.0/x", + "http://[::]/x", + "http://100.64.0.1/cgnat", + "http://[::ffff:127.0.0.1]/v4mapped", + "http://[::ffff:10.0.0.1]/v4mappedpriv", + "http://224.0.0.1/multicast", + } + for _, raw := range cases { + err := request.ValidateExternalURL(ctx, raw, request.SSRFOptions{}) + assert.ErrorIs(t, err, request.ErrUnsafeURL, "raw=%q", raw) + } +} + +func TestValidateExternalURL_PublicIP(t *testing.T) { + ctx := context.Background() + cases := []string{ + "http://1.1.1.1/", + "https://8.8.8.8/", + "http://[2606:4700:4700::1111]/", + } + for _, raw := range cases { + err := request.ValidateExternalURL(ctx, raw, request.SSRFOptions{}) + assert.NoError(t, err, "raw=%q", raw) + } +} + +func TestValidateExternalURL_AllowedHostBypass(t *testing.T) { + ctx := context.Background() + + opt := request.SSRFOptions{AllowedHosts: []string{"localhost"}} + for _, raw := range []string{ + "http://localhost/file.zip", + "http://LOCALHOST:8080/file.zip", + } { + assert.NoError(t, request.ValidateExternalURL(ctx, raw, opt), "raw=%q", raw) + } + + opt = request.SSRFOptions{AllowedHosts: []string{"cloudreve.example.com"}} + assert.NoError(t, request.ValidateExternalURL(ctx, "https://cloudreve.example.com/x", opt)) + + opt = request.SSRFOptions{AllowedHosts: []string{"127.0.0.1"}} + assert.NoError(t, request.ValidateExternalURL(ctx, "http://127.0.0.1:7777/secret", opt)) +} + +func TestValidateExternalURL_Disabled(t *testing.T) { + ctx := context.Background() + opt := request.SSRFOptions{Disabled: true} + + // Everything passes when the operator has explicitly disabled validation. + for _, raw := range []string{ + "http://127.0.0.1:7777/secret", + "http://localhost/x", + "file:///etc/passwd", + "http://169.254.169.254/latest", + } { + assert.NoError(t, request.ValidateExternalURL(ctx, raw, opt), "raw=%q", raw) + } +} + +func TestValidateExternalURL_AllowedCIDR(t *testing.T) { + ctx := context.Background() + + // LAN range allowlist lets a NAS at 192.168.10.50 through. + opt := request.SSRFOptions{AllowedCIDRs: []string{"192.168.10.0/24"}} + assert.NoError(t, request.ValidateExternalURL(ctx, + "http://192.168.10.50/share/file.zip", opt)) + + // IPs outside the allowlisted CIDR remain rejected. + err := request.ValidateExternalURL(ctx, + "http://192.168.11.50/share/file.zip", opt) + assert.ErrorIs(t, err, request.ErrUnsafeURL) + + // IPv6 ULA allowlist. + opt = request.SSRFOptions{AllowedCIDRs: []string{"fd00::/8"}} + assert.NoError(t, request.ValidateExternalURL(ctx, + "http://[fd12:3456::1]/x", opt)) + + // Malformed CIDRs are silently dropped without affecting other entries. + opt = request.SSRFOptions{AllowedCIDRs: []string{"not-a-cidr", "10.0.0.0/8"}} + assert.NoError(t, request.ValidateExternalURL(ctx, "http://10.5.5.5/x", opt)) +} + +func TestValidateExternalURL_Magnet(t *testing.T) { + ctx := context.Background() + + // DHT-only magnet (no tracker/web seed) is allowed. + assert.NoError(t, request.ValidateExternalURL(ctx, + "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a", + request.SSRFOptions{})) + + // Internal tracker URL is rejected. + err := request.ValidateExternalURL(ctx, + "magnet:?xt=urn:btih:abc&tr=http://127.0.0.1:7777/announce", + request.SSRFOptions{}) + assert.ErrorIs(t, err, request.ErrUnsafeURL) + + // Internal web seed is rejected. + err = request.ValidateExternalURL(ctx, + "magnet:?xt=urn:btih:abc&ws=http://10.0.0.1/seed", + request.SSRFOptions{}) + assert.ErrorIs(t, err, request.ErrUnsafeURL) + + // Public tracker is allowed. + assert.NoError(t, request.ValidateExternalURL(ctx, + "magnet:?xt=urn:btih:abc&tr=udp://tracker.opentrackr.org:1337/announce", + request.SSRFOptions{})) +}