package io import ( "context" "errors" "fmt" "net" "net/url" "strings" ) // ErrSSRF is returned when a requested URL targets a loopback, private or // link-local address or a localhost hostname that is not on the allowlist. // Use errors.Is to test for it. var ErrSSRF = errors.New("io: disallowed url") // ValidateRequestURL is the exported form of validateRequestURL: the single SSRF // policy for every code path that fetches a caller-supplied URL on the server's // behalf. It is exported because a second implementation of the same policy grew // outside this package — `internal/capability/browser` carried its own copy // (written when this function was unexported and that capability was unreachable // from the slice being implemented), which is what design/28 §4 forbids // ("reuse the existing io/ssrf allowlist, do not add a third implementation"). // That copy is gone and the browser capability calls this function instead, so // the policy table (scheme check → allowlist → literal-IP deny → // resolve-and-deny) lives in one place again. Call sites inside this package // keep using the unexported name. func ValidateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { return validateRequestURL(ctx, rawURL, allowed) } // validateRequestURL parses rawURL and refuses SSRF-prone targets: it returns an // error wrapping ErrSSRF for loopback/private/link-local/unspecified addresses // and for localhost hostnames, unless the host (or hostname) is on the explicit // allowlist. A hostname that resolves to any denied address is also refused. // // 内部调用点沿用未导出名;对外只有 ValidateRequestURL 一个出口。 func validateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { if strings.TrimSpace(rawURL) == "" { return nil, fmt.Errorf("io: url is required") } u, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("io: parse url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("io: unsupported scheme %q", u.Scheme) } host := hostOnly(u.Host) if host == "" { return nil, fmt.Errorf("io: url has no host") } if isHostAllowed(host, allowed) { return u, nil } if ip := net.ParseIP(host); ip != nil { if isDeniedIP(ip) { return nil, fmt.Errorf("%w: %s", ErrSSRF, host) } return u, nil } addrs, err := net.DefaultResolver.LookupHost(ctx, host) if err != nil { return nil, fmt.Errorf("io: resolve %q: %w", host, err) } for _, a := range addrs { if ip := net.ParseIP(a); ip != nil && isDeniedIP(ip) { return nil, fmt.Errorf("%w: %q resolves to %q", ErrSSRF, host, a) } } return u, nil } // isDeniedIP reports whether ip is a loopback, private, link-local, multicast or // unspecified address — the targets SSRF protection refuses by default. func isDeniedIP(ip net.IP) bool { if ip == nil { return false } return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() } // isHostAllowed reports whether the host (with any port stripped) matches an // entry on the allowlist. func isHostAllowed(host string, allowed []string) bool { h := strings.ToLower(hostOnly(host)) if h == "" { return false } for _, a := range allowed { if strings.EqualFold(allowEntryHost(a), h) { return true } } return false } // allowEntryHost returns the hostname an allowlist entry stands for. Entries are // written by hand (panel config, tool options), so all three natural spellings // are accepted: `host`, `host:port` and a full URL. // // URL entries used to be honoured only by the browser-side copy of this policy // (now deleted); folding that capability in here is what keeps the collapse into // one implementation from silently weakening an existing allowlist — an entry // spelled as a URL would otherwise stop matching after the merge. A bare IPv6 // literal (`::1`) is also returned as-is: the host:port split must not cut it. func allowEntryHost(entry string) string { raw := strings.TrimSpace(entry) if raw == "" { return "" } if u, err := url.Parse(raw); err == nil && u.Host != "" { if h := u.Hostname(); h != "" { return h } } return hostOnly(raw) } // hostOnly strips the port (and any IPv6 brackets) from a host:port string. // // A **bare** IPv6 literal (`::1`, no brackets) is returned unchanged: the port // split would otherwise cut it at its last colon, and callers do pass // already-hostname-only strings (isHostAllowed normalizes its argument too). func hostOnly(hostport string) string { h := strings.TrimSpace(hostport) if h == "" { return "" } if strings.HasPrefix(h, "[") { if j := strings.Index(h, "]"); j >= 0 { return h[1:j] } return h } if net.ParseIP(h) != nil { return h } if i := strings.LastIndex(h, ":"); i >= 0 { return h[:i] } return h }