Files
Cyrene_v2/plugins/tools/io/ssrf.go
T

102 lines
2.6 KiB
Go

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 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.
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(hostOnly(a), h) {
return true
}
}
return false
}
// hostOnly strips the port (and any IPv6 brackets) from a host:port string.
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 i := strings.LastIndex(h, ":"); i >= 0 {
return h[:i]
}
return h
}