|
| 1 | +package registries |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "fmt" |
| 7 | + "net" |
| 8 | + "net/url" |
| 9 | + "strings" |
| 10 | + "sync/atomic" |
| 11 | + "syscall" |
| 12 | +) |
| 13 | + |
| 14 | +// registryResolveHost resolves a hostname to its IPs. A package var so tests can |
| 15 | +// simulate a hostname resolving into a blocked range without real DNS. |
| 16 | +var registryResolveHost = func(ctx context.Context, host string) ([]net.IP, error) { |
| 17 | + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) |
| 18 | + if err != nil { |
| 19 | + return nil, err |
| 20 | + } |
| 21 | + ips := make([]net.IP, len(addrs)) |
| 22 | + for i := range addrs { |
| 23 | + ips[i] = addrs[i].IP |
| 24 | + } |
| 25 | + return ips, nil |
| 26 | +} |
| 27 | + |
| 28 | +// ErrBlockedRegistryHost is returned when a registry URL targets — or resolves |
| 29 | +// to — a non-routable / internal address. It bounds CWE-918 (request forgery): |
| 30 | +// a malicious or typo'd registry source must never let the daemon fetch from |
| 31 | +// loopback, RFC1918/CGNAT private space, link-local (incl. the |
| 32 | +// 169.254.169.254 cloud-metadata endpoint), or other internal ranges. |
| 33 | +var ErrBlockedRegistryHost = errors.New("registry host is not allowed (internal/non-routable address)") |
| 34 | + |
| 35 | +// registryAllowPrivateFetch relaxes the SSRF guard so a fetch may reach |
| 36 | +// loopback/private targets. It is OFF by default (secure) and set from the |
| 37 | +// user's `allow_private_registry_fetch` config flag by SetRegistriesFromConfig |
| 38 | +// — the opt-in allow-policy for operators who run a trusted registry mirror on |
| 39 | +// an internal/private address. atomic.Bool because it is written on config |
| 40 | +// (re)load while concurrent fetches read it on the dial path. The registries |
| 41 | +// test binary also flips it on (httptest servers bind 127.0.0.1). |
| 42 | +var registryAllowPrivateFetch atomic.Bool |
| 43 | + |
| 44 | +// testForceAllowPrivate pins the guard open regardless of config. It is set ONLY |
| 45 | +// by the registries test binary (httptest servers bind loopback) so a fetch |
| 46 | +// still works across a SetRegistriesFromConfig(defaultConfig) call that would |
| 47 | +// otherwise reset the flag to false. Always false in production builds. |
| 48 | +var testForceAllowPrivate atomic.Bool |
| 49 | + |
| 50 | +// SetAllowPrivateRegistryFetch sets the SSRF allow-policy from config. Exposed so |
| 51 | +// SetRegistriesFromConfig (and only it) can propagate the user's flag. The |
| 52 | +// test-force override keeps loopback fetches working in the test binary. |
| 53 | +func SetAllowPrivateRegistryFetch(allow bool) { |
| 54 | + registryAllowPrivateFetch.Store(allow || testForceAllowPrivate.Load()) |
| 55 | +} |
| 56 | + |
| 57 | +// isBlockedIP reports whether ip falls in a range a registry fetch must never |
| 58 | +// reach. This is the single predicate behind both the pre-flight URL check and |
| 59 | +// the dial-time Control guard, so the policy lives in exactly one place. |
| 60 | +// |
| 61 | +// Blocked: loopback, RFC1918 private (10/8, 172.16/12, 192.168/16), IPv6 |
| 62 | +// unique-local (fc00::/7, via IsPrivate), RFC6598 CGNAT (100.64/10), link-local |
| 63 | +// unicast (169.254/16, fe80::/10 — covers the cloud metadata endpoint), |
| 64 | +// link-local & interface-local multicast, any other multicast, and the |
| 65 | +// unspecified address. A nil/unparseable IP fails closed (blocked). |
| 66 | +func isBlockedIP(ip net.IP) bool { |
| 67 | + if ip == nil { |
| 68 | + return true // fail closed: an address we can't reason about is not safe |
| 69 | + } |
| 70 | + // RFC6598 carrier-grade NAT (100.64.0.0/10) is not covered by IsPrivate. |
| 71 | + if ip4 := ip.To4(); ip4 != nil { |
| 72 | + if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { |
| 73 | + return true |
| 74 | + } |
| 75 | + } |
| 76 | + return ip.IsLoopback() || |
| 77 | + ip.IsPrivate() || |
| 78 | + ip.IsLinkLocalUnicast() || |
| 79 | + ip.IsLinkLocalMulticast() || |
| 80 | + ip.IsInterfaceLocalMulticast() || |
| 81 | + ip.IsMulticast() || |
| 82 | + ip.IsUnspecified() |
| 83 | +} |
| 84 | + |
| 85 | +// hostLiteralBlocked returns a non-nil error if host is a LITERAL IP in a blocked |
| 86 | +// range. host may be a bare host, host:port, or a bracketed IPv6 literal. A |
| 87 | +// hostname (not an IP literal) returns nil here — hostnames are validated |
| 88 | +// authoritatively at dial time (registryDialControl), which also defeats |
| 89 | +// DNS-rebinding TOCTOU. allowPrivate (the config opt-in) short-circuits to nil. |
| 90 | +func hostLiteralBlocked(host string, allowPrivate bool) error { |
| 91 | + if allowPrivate { |
| 92 | + return nil |
| 93 | + } |
| 94 | + h := host |
| 95 | + if hh, _, err := net.SplitHostPort(host); err == nil { |
| 96 | + h = hh |
| 97 | + } |
| 98 | + h = strings.Trim(h, "[]") |
| 99 | + ip := net.ParseIP(h) |
| 100 | + if ip == nil { |
| 101 | + return nil // not a literal IP — defer to the dial-time guard |
| 102 | + } |
| 103 | + if isBlockedIP(ip) { |
| 104 | + return fmt.Errorf("%w: %s", ErrBlockedRegistryHost, ip) |
| 105 | + } |
| 106 | + return nil |
| 107 | +} |
| 108 | + |
| 109 | +// ValidateRegistrySourceURL is the add-source / edit-source fail-fast: it rejects |
| 110 | +// a user-supplied registry URL whose host is a literal IP in a blocked range, so |
| 111 | +// `registry add-source https://169.254.169.254/...` is refused up front with a |
| 112 | +// clear error instead of failing later at fetch time. It performs NO DNS lookup |
| 113 | +// (keeping add/edit pure and offline) — hostname sources pass here and are |
| 114 | +// guarded authoritatively when the daemon actually dials them. |
| 115 | +func ValidateRegistrySourceURL(rawURL string) error { |
| 116 | + u, err := url.Parse(strings.TrimSpace(rawURL)) |
| 117 | + if err != nil { |
| 118 | + return fmt.Errorf("invalid registry URL: %w", err) |
| 119 | + } |
| 120 | + return hostLiteralBlocked(u.Host, registryAllowPrivateFetch.Load()) |
| 121 | +} |
| 122 | + |
| 123 | +// registryDialControl is the authoritative SSRF guard. It is wired as the |
| 124 | +// net.Dialer Control hook on the shared registry HTTP client, so it runs with |
| 125 | +// the ACTUAL resolved address the connection is about to dial — after DNS |
| 126 | +// resolution and before connect. This catches hostnames that resolve into |
| 127 | +// blocked ranges and closes the DNS-rebinding TOCTOU window that a parse-time |
| 128 | +// check alone leaves open. |
| 129 | +func registryDialControl(_, address string, _ syscall.RawConn) error { |
| 130 | + if registryAllowPrivateFetch.Load() { |
| 131 | + return nil |
| 132 | + } |
| 133 | + host, _, err := net.SplitHostPort(address) |
| 134 | + if err != nil { |
| 135 | + host = address |
| 136 | + } |
| 137 | + ip := net.ParseIP(host) |
| 138 | + if isBlockedIP(ip) { |
| 139 | + return fmt.Errorf("%w: %s", ErrBlockedRegistryHost, address) |
| 140 | + } |
| 141 | + return nil |
| 142 | +} |
| 143 | + |
| 144 | +// guardRegistryTargetHost is the application-layer SSRF guard: it resolves the |
| 145 | +// registry TARGET host and rejects the fetch if ANY resolved address is in a |
| 146 | +// blocked range. Unlike registryDialControl, it runs BEFORE the request and is |
| 147 | +// independent of the transport, so it holds even when an HTTP(S)_PROXY is set — |
| 148 | +// in which case the dialer connects to the proxy and the dial-time Control only |
| 149 | +// ever sees the proxy's IP, never the real target (the proxy resolves the host |
| 150 | +// itself). The dial-time guard remains as defense-in-depth for the direct |
| 151 | +// (no-proxy) path. Caveats: a literal-IP host is already covered by |
| 152 | +// validateRegistryURL; a DNS lookup failure is left to the request to surface |
| 153 | +// (fail-open on resolver error so a flaky resolver does not break every fetch — |
| 154 | +// the dial guard still covers the no-proxy path). Relaxed by the |
| 155 | +// allow_private_registry_fetch opt-in. |
| 156 | +func guardRegistryTargetHost(ctx context.Context, reqURL string) error { |
| 157 | + if registryAllowPrivateFetch.Load() { |
| 158 | + return nil |
| 159 | + } |
| 160 | + u, err := url.Parse(reqURL) |
| 161 | + if err != nil { |
| 162 | + return fmt.Errorf("invalid request URL: %w", err) |
| 163 | + } |
| 164 | + host := u.Hostname() // strips the port and unbrackets an IPv6 literal |
| 165 | + if host == "" { |
| 166 | + return nil |
| 167 | + } |
| 168 | + if ip := net.ParseIP(host); ip != nil { |
| 169 | + // Literal IP — already validated pre-flight; re-check defensively. |
| 170 | + if isBlockedIP(ip) { |
| 171 | + return fmt.Errorf("%w: %s", ErrBlockedRegistryHost, ip) |
| 172 | + } |
| 173 | + return nil |
| 174 | + } |
| 175 | + ips, err := registryResolveHost(ctx, host) |
| 176 | + if err != nil { |
| 177 | + // Resolution failed — let the request itself surface the failure. |
| 178 | + return nil //nolint:nilerr // fail-open on lookup error; dial guard covers no-proxy |
| 179 | + } |
| 180 | + for _, ip := range ips { |
| 181 | + if isBlockedIP(ip) { |
| 182 | + return fmt.Errorf("%w: host %q resolves to %s", ErrBlockedRegistryHost, host, ip) |
| 183 | + } |
| 184 | + } |
| 185 | + return nil |
| 186 | +} |
0 commit comments