mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
212 lines
6.4 KiB
Go
212 lines
6.4 KiB
Go
package safehttp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxRedirects = 10
|
|
|
|
var blockedDownloadPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/8"),
|
|
netip.MustParsePrefix("127.0.0.0/8"),
|
|
netip.MustParsePrefix("169.254.0.0/16"),
|
|
netip.MustParsePrefix("192.0.0.0/24"),
|
|
netip.MustParsePrefix("192.0.2.0/24"),
|
|
netip.MustParsePrefix("192.88.99.0/24"),
|
|
netip.MustParsePrefix("198.18.0.0/15"),
|
|
netip.MustParsePrefix("198.51.100.0/24"),
|
|
netip.MustParsePrefix("203.0.113.0/24"),
|
|
netip.MustParsePrefix("224.0.0.0/4"),
|
|
netip.MustParsePrefix("240.0.0.0/4"),
|
|
netip.MustParsePrefix("::/128"),
|
|
netip.MustParsePrefix("::1/128"),
|
|
netip.MustParsePrefix("64:ff9b::/96"),
|
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
|
netip.MustParsePrefix("100::/64"),
|
|
netip.MustParsePrefix("2001::/32"),
|
|
netip.MustParsePrefix("2001:2::/48"),
|
|
netip.MustParsePrefix("2001:db8::/32"),
|
|
netip.MustParsePrefix("2001:20::/28"),
|
|
netip.MustParsePrefix("2002::/16"),
|
|
netip.MustParsePrefix("fec0::/10"),
|
|
netip.MustParsePrefix("fe80::/10"),
|
|
netip.MustParsePrefix("ff00::/8"),
|
|
}
|
|
|
|
// ValidateURL performs the URL checks that do not require DNS. Host addresses
|
|
// are checked again after resolution and immediately before every connection.
|
|
func ValidateURL(rawURL string) (*url.URL, error) {
|
|
if len(rawURL) == 0 || len(rawURL) > 4096 {
|
|
return nil, fmt.Errorf("download URL must be between 1 and 4096 characters")
|
|
}
|
|
parsed, err := url.ParseRequestURI(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid download URL: %v", err)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return nil, fmt.Errorf("download URL must use HTTP or HTTPS")
|
|
}
|
|
if parsed.Host == "" || parsed.Hostname() == "" {
|
|
return nil, fmt.Errorf("download URL must include a host")
|
|
}
|
|
if parsed.User != nil {
|
|
return nil, fmt.Errorf("download URL must not include credentials")
|
|
}
|
|
if parsed.Fragment != "" {
|
|
return nil, fmt.Errorf("download URL must not include a fragment")
|
|
}
|
|
if port := parsed.Port(); port != "" {
|
|
value, err := strconv.Atoi(port)
|
|
if err != nil || value < 1 || value > 65535 {
|
|
return nil, fmt.Errorf("download URL contains an invalid port")
|
|
}
|
|
}
|
|
if addr, err := netip.ParseAddr(parsed.Hostname()); err == nil && !isAllowedDownloadAddress(addr) {
|
|
return nil, fmt.Errorf("download URL resolves to a blocked address")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// Get retrieves a resource only when every resolved destination is safe for
|
|
// image downloads. Private network image mirrors are allowed; loopback,
|
|
// link-local, metadata, multicast and reserved destinations remain blocked.
|
|
func Get(ctx context.Context, rawURL, userAgent string, timeout time.Duration) (*http.Response, error) {
|
|
parsed, err := ValidateURL(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateHost(ctx, net.DefaultResolver, parsed.Hostname()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("User-Agent", userAgent)
|
|
|
|
client := &http.Client{
|
|
Timeout: timeout,
|
|
Transport: restrictedTransport(net.DefaultResolver),
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= maxRedirects {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
redirect, err := ValidateURL(req.URL.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateHost(req.Context(), net.DefaultResolver, redirect.Hostname()); err != nil {
|
|
return err
|
|
}
|
|
if len(via) > 0 {
|
|
req.Header.Set("User-Agent", via[0].Header.Get("User-Agent"))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// All URL components, redirects, DNS answers and dial destinations are
|
|
// constrained above and in restrictedTransport.
|
|
// lgtm[go/request-forgery]
|
|
return client.Do(request)
|
|
}
|
|
|
|
func restrictedTransport(resolver *net.Resolver) *http.Transport {
|
|
dialer := &net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
return &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid download destination: %v", err)
|
|
}
|
|
addresses, err := resolveAllowedHost(ctx, resolver, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var lastErr error
|
|
for _, addr := range addresses {
|
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("host has no usable public addresses")
|
|
}
|
|
return nil, lastErr
|
|
},
|
|
ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: 30 * time.Second,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
}
|
|
}
|
|
|
|
func validateHost(ctx context.Context, resolver *net.Resolver, host string) error {
|
|
_, err := resolveAllowedHost(ctx, resolver, host)
|
|
return err
|
|
}
|
|
|
|
func resolveAllowedHost(ctx context.Context, resolver *net.Resolver, host string) ([]netip.Addr, error) {
|
|
host = strings.TrimSpace(strings.TrimSuffix(host, "."))
|
|
if host == "" {
|
|
return nil, fmt.Errorf("download URL host is empty")
|
|
}
|
|
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
|
|
return nil, fmt.Errorf("download URL host is not public")
|
|
}
|
|
|
|
if addr, err := netip.ParseAddr(host); err == nil {
|
|
addr = addr.Unmap()
|
|
if !isAllowedDownloadAddress(addr) {
|
|
return nil, fmt.Errorf("download URL resolves to a blocked address")
|
|
}
|
|
return []netip.Addr{addr}, nil
|
|
}
|
|
|
|
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve download host: %v", err)
|
|
}
|
|
if len(addresses) == 0 {
|
|
return nil, fmt.Errorf("download host has no IP addresses")
|
|
}
|
|
result := make([]netip.Addr, 0, len(addresses))
|
|
for _, address := range addresses {
|
|
address = address.Unmap()
|
|
if !isAllowedDownloadAddress(address) {
|
|
return nil, fmt.Errorf("download host resolves to a blocked address")
|
|
}
|
|
result = append(result, address)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func isAllowedDownloadAddress(address netip.Addr) bool {
|
|
if !address.IsValid() || address.Zone() != "" || !address.IsGlobalUnicast() ||
|
|
address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
|
|
address.IsMulticast() || address.IsUnspecified() {
|
|
return false
|
|
}
|
|
address = address.Unmap()
|
|
for _, prefix := range blockedDownloadPrefixes {
|
|
if prefix.Contains(address) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|