You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
291 lines
8.6 KiB
291 lines
8.6 KiB
package request
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"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 {
|
|
// Normalize IPv4-in-IPv6 transition forms so both the allowlist and the
|
|
// class check operate on the address the packet actually reaches.
|
|
ip = effectiveIP(ip)
|
|
for _, n := range allowed {
|
|
if n.Contains(ip) {
|
|
return nil
|
|
}
|
|
}
|
|
return checkIP(ip)
|
|
}
|
|
|
|
// nat64WellKnownPrefix is 64:ff9b::/96 (RFC 6052 §2.1), the 12-byte prefix
|
|
// that wraps an IPv4 address in its last 32 bits for NAT64 gateways.
|
|
var nat64WellKnownPrefix = []byte{
|
|
0x00, 0x64, 0xff, 0x9b,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
}
|
|
|
|
// teredoPrefix is 2001:0000::/32 (RFC 4380). In Teredo, the client's IPv4 is
|
|
// the last 32 bits of the address XORed with 0xff.
|
|
var teredoPrefix = []byte{0x20, 0x01, 0x00, 0x00}
|
|
|
|
// effectiveIP unwraps IPv4-in-IPv6 transition forms to the IPv4 address the
|
|
// packet ultimately reaches, so checkIP classifies the real target rather
|
|
// than the (often globally-routable) IPv6 wrapper. Go's net.IP builtins
|
|
// (IsLoopback, IsPrivate, IsLinkLocalUnicast, ...) inspect only the outer
|
|
// address and would otherwise accept, for example, 64:ff9b::a9fe:a9fe as a
|
|
// safe public IPv6 even though it routes to 169.254.169.254 on a NAT64 host.
|
|
//
|
|
// Covers:
|
|
// - IPv4-mapped ::ffff:a.b.c.d (RFC 4291 §2.5.5.2)
|
|
// - NAT64 WKP 64:ff9b::a.b.c.d (RFC 6052 §2.1)
|
|
// - 6to4 2002:AABB:CCDD:: (RFC 3056)
|
|
// - Teredo 2001:0000:... (RFC 4380)
|
|
// - IPv4-compatible ::a.b.c.d (RFC 4291 §2.5.5.1, deprecated)
|
|
func effectiveIP(ip net.IP) net.IP {
|
|
if ip == nil {
|
|
return nil
|
|
}
|
|
// IPv4 literal or IPv4-mapped ::ffff:a.b.c.d — To4 returns the embedded v4.
|
|
if v4 := ip.To4(); v4 != nil {
|
|
return v4
|
|
}
|
|
v6 := ip.To16()
|
|
if v6 == nil {
|
|
return ip
|
|
}
|
|
|
|
// NAT64 well-known prefix 64:ff9b::/96.
|
|
if bytes.HasPrefix(v6, nat64WellKnownPrefix) {
|
|
return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
|
|
}
|
|
|
|
// 6to4 2002::/16 — bytes 2-5 encode the embedded IPv4.
|
|
if v6[0] == 0x20 && v6[1] == 0x02 {
|
|
return net.IPv4(v6[2], v6[3], v6[4], v6[5]).To4()
|
|
}
|
|
|
|
// Teredo 2001:0000::/32 — client IPv4 is last 4 bytes XOR 0xff.
|
|
if bytes.HasPrefix(v6, teredoPrefix) {
|
|
return net.IPv4(v6[12]^0xff, v6[13]^0xff, v6[14]^0xff, v6[15]^0xff).To4()
|
|
}
|
|
|
|
// IPv4-compatible ::a.b.c.d — top 12 bytes are zero. Skip :: and ::1 so
|
|
// the outer classifier still catches them as unspecified/loopback IPv6.
|
|
var zero [12]byte
|
|
if bytes.Equal(v6[:12], zero[:]) {
|
|
if last := binary.BigEndian.Uint32(v6[12:16]); last > 1 {
|
|
return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
|
|
}
|
|
}
|
|
|
|
return ip
|
|
}
|
|
|
|
func checkIP(ip net.IP) error {
|
|
ip = effectiveIP(ip)
|
|
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
|
|
}
|