feat(remote download): configurable SSRF check for target URL

pull/3472/head
Aaron Liu 2 months ago
parent ed20843dc3
commit aaebf317a7

@ -1 +1 @@
Subproject commit 1678ce8a748123ffc078ef958c6e68523a999128
Subproject commit bf9c1ff7c196c6e3759bacdbf496d5dc2e2d2c35

@ -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

@ -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

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

@ -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{}))
}
Loading…
Cancel
Save