Keep the control plane's fetches off link-local addresses, and see through NAT64, 6to4 and proxies

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 10:48:01 +02:00
1 parent fbac1aa664
commit a56ad8e4c4
8 files changed
+260 -60

No files matched your search

+8
View File
@@ -171,6 +171,14 @@ APIs change between minors without a deprecation period.
torchaudio; every other package comes from PyPI alone, and the lock is
PEP 751's (`pylock.llmgw.toml`), pinning each file's address as well as
its hash. Checked against vLLM 0.30.0: the same 195 packages resolve.
- **The control plane's own fetches stay off link-local addresses.**
Checking a model's source and reading a GGUF's header went wherever the
URL said, cloud instance metadata (169.254.169.254) included. They now
refuse link-local, multicast and unspecified addresses, while still
reaching the cluster's network, where the mirror is. The mirror's fetches,
which refuse private addresses too, now also see through IPv6 addresses
carrying an IPv4 one (NAT64, 6to4, IPv4-compatible), and through a proxy:
the target's addresses are checked before the request is handed to it.
### Everything that can be changed on a running cluster, from the console
+3 -2
View File
@@ -24,6 +24,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/safedial"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
@@ -1715,7 +1716,7 @@ func (a *API) setAdapters(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "no model "+name)
return
}
base, baseErr := gguf.KindFromURL(r.Context(), nil, m.Source)
base, baseErr := gguf.KindFromURL(r.Context(), safedial.Client(safedial.ControlPlane, 30*time.Second), m.Source)
have := map[string]bool{}
for _, ad := range m.Adapters {
have[ad.Source] = true
@@ -1724,7 +1725,7 @@ func (a *API) setAdapters(w http.ResponseWriter, r *http.Request) {
if have[ad.Source] {
continue // already checked when it was attached
}
h, err := gguf.KindFromURL(r.Context(), nil, ad.Source)
h, err := gguf.KindFromURL(r.Context(), safedial.Client(safedial.ControlPlane, 30*time.Second), ad.Source)
switch {
case err != nil:
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s: %v", ad.Name, err))
+3 -1
View File
@@ -446,6 +446,8 @@ func TestTheAPIWillNotFetchFromThePrivateNetwork(t *testing.T) {
for _, src := range []string{
"http://127.0.0.1:1/x.gguf", "http://169.254.169.254/latest/meta-data",
"http://10.0.0.140:9090/api/v1/nodes", "http://[::1]:1/x",
// The metadata address inside NAT64 and 6to4 ones.
"http://[64:ff9b::a9fe:a9fe]/latest", "http://[2002:a9fe:a9fe::1]/latest",
} {
st, err := m.Start(src, Entry{Model: "m", Version: "v", File: "x.gguf"})
if err != nil {
@@ -456,7 +458,7 @@ func TestTheAPIWillNotFetchFromThePrivateNetwork(t *testing.T) {
time.Sleep(10 * time.Millisecond)
st, _ = m.Status("m", "v")
}
if st.State != Failed || !strings.Contains(st.Err, "refusing to fetch") {
if st.State != Failed || !strings.Contains(st.Err, "refusing to reach") {
t.Errorf("%s: %+v", src, st)
}
m.Forget("m", "v")
+10 -55
View File
@@ -1,80 +1,35 @@
package mirror
import (
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/safedial"
)
// errPrivateSource is a fetch refused because it would reach this machine or
// the network behind it rather than the internet.
var errPrivateSource = errors.New("refusing to fetch from a loopback, private or link-local address")
var errPrivateSource = safedial.ErrBlocked
// fetchClient downloads sources for the management API.
//
// The API fetches whatever URL it is given and the file server then hands the
// result to anyone. Without this, "fetch http://169.254.169.254/..." was a way
// to read a cloud's instance metadata — or anything else on the network the
// mirror sits in — through the mirror. The check is made on the address
// actually dialled, after resolution and on every redirect, so a name that
// resolves somewhere public and then somewhere private is caught too.
// mirror sits in — through the mirror. No overall timeout: a model is tens of
// gigabytes, and after the first byte a stall is the context's to end.
func fetchClient(allowPrivate bool) *http.Client {
d := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
if !allowPrivate {
d.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
a, err := netip.ParseAddr(host)
if err != nil {
return err
}
if blockedAddr(a.Unmap()) {
return fmt.Errorf("%w (%s)", errPrivateSource, a)
}
return nil
}
}
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: d.DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 2 * time.Minute,
ForceAttemptHTTP2: true,
},
// No overall timeout: a model is tens of gigabytes. The dial, the
// handshake and the first byte are bounded above; after that a stall
// is the context's to end.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("too many redirects")
}
return checkScheme(req.URL)
},
if allowPrivate {
return safedial.Client(safedial.Policy{Private: true, Loopback: true, LinkLocal: true}, 0)
}
return safedial.Client(safedial.Policy{}, 0)
}
func blockedAddr(a netip.Addr) bool {
cgnat := netip.MustParsePrefix("100.64.0.0/10")
return a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() ||
a.IsLinkLocalMulticast() || a.IsMulticast() || a.IsUnspecified() || cgnat.Contains(a)
}
func blockedAddr(a netip.Addr) bool { return safedial.Blocked(a, safedial.Policy{}) }
// checkScheme allows the two schemes a model is published over. file:// and
// the rest would read this machine's own disk.
func checkScheme(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q is not an http or https source", u.Redacted())
}
return nil
}
func checkScheme(u *url.URL) error { return safedial.CheckScheme(u) }
// validSource checks a source before a download is started for it.
func validSource(source string) error {
+160
View File
@@ -0,0 +1,160 @@
// Package safedial makes HTTP clients that refuse to reach addresses a
// fetch on someone else's behalf has no business reaching: a cloud's
// instance metadata, this machine, the network behind it.
//
// The check is made on the address actually dialled, after resolution and on
// every redirect, so a name that resolves somewhere public and then somewhere
// private is caught too. IPv6 addresses that carry an IPv4 one — NAT64,
// 6to4, IPv4-mapped and -compatible — are judged by the IPv4 address inside.
package safedial
import (
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
"time"
)
// Policy is what may be reached besides the internet.
type Policy struct {
// Private allows RFC 1918, unique-local and carrier-grade NAT addresses:
// a control plane's own mirror lives on one.
Private bool
// Loopback allows this machine.
Loopback bool
// LinkLocal allows link-local addresses, instance metadata's included:
// only for a mirror told to with --allow-private-sources.
LinkLocal bool
}
// ErrBlocked is a fetch refused for where it would go.
var ErrBlocked = errors.New("refusing to reach a loopback, private or link-local address")
var (
cgnat = netip.MustParsePrefix("100.64.0.0/10")
nat64 = netip.MustParsePrefix("64:ff9b::/96")
nat64l = netip.MustParsePrefix("64:ff9b:1::/48")
sixTo4 = netip.MustParsePrefix("2002::/16")
compat = netip.MustParsePrefix("::/96")
)
// Blocked reports whether p refuses an address.
func Blocked(a netip.Addr, p Policy) bool {
a = a.Unmap()
if v4, ok := embedded(a); ok {
a = v4
}
switch {
case a.IsLinkLocalUnicast(), a.IsLinkLocalMulticast():
// Instance metadata lives at 169.254.169.254, and nothing a model is
// published from does.
return !p.LinkLocal
case a.IsMulticast(), a.IsUnspecified(), a.IsInterfaceLocalMulticast():
return !p.LinkLocal
case a.IsLoopback():
return !p.Loopback
case a.IsPrivate(), cgnat.Contains(a):
return !p.Private
}
return false
}
// embedded is the IPv4 address an IPv6 one carries, if it carries one.
func embedded(a netip.Addr) (netip.Addr, bool) {
if !a.Is6() {
return netip.Addr{}, false
}
b := a.As16()
switch {
case nat64.Contains(a):
return netip.AddrFrom4([4]byte(b[12:16])), true
case nat64l.Contains(a):
// RFC 8215's local-use prefix: the address in the last 32 bits too.
return netip.AddrFrom4([4]byte(b[12:16])), true
case sixTo4.Contains(a):
return netip.AddrFrom4([4]byte(b[2:6])), true
case compat.Contains(a) && !a.IsLoopback() && !a.IsUnspecified():
return netip.AddrFrom4([4]byte(b[12:16])), true
}
return netip.Addr{}, false
}
// Client is an HTTP client under a policy. timeout bounds a whole request;
// zero leaves it to the context, for downloads tens of gigabytes long.
//
// A proxy from the environment is used, since sites reach the internet
// through one; the dial check then sees only the proxy, so the target's own
// addresses are checked before the request is handed to it.
func Client(p Policy, timeout time.Duration) *http.Client {
d := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
d.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
a, err := netip.ParseAddr(host)
if err != nil {
return err
}
if Blocked(a, p) {
return fmt.Errorf("%w (%s)", ErrBlocked, a)
}
return nil
}
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
Proxy: proxy(p),
DialContext: d.DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 2 * time.Minute,
ForceAttemptHTTP2: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("too many redirects")
}
return CheckScheme(req.URL)
},
}
}
// proxy is the environment's proxy, for targets the policy allows.
func proxy(p Policy) func(*http.Request) (*url.URL, error) {
return func(req *http.Request) (*url.URL, error) {
u, err := http.ProxyFromEnvironment(req)
if err != nil || u == nil {
return u, err
}
addrs, err := net.DefaultResolver.LookupNetIP(req.Context(), "ip", req.URL.Hostname())
if err != nil {
return nil, err
}
for _, a := range addrs {
if Blocked(a, p) {
return nil, fmt.Errorf("%w (%s)", ErrBlocked, a)
}
}
return u, nil
}
}
// CheckScheme allows the two schemes a model is published over. file:// and
// the rest would read this machine's own disk.
func CheckScheme(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q is not an http or https source", u.Redacted())
}
return nil
}
// ControlPlane is the policy for fetches a control plane makes on an
// operator's word — checking that a model's source is there, reading a
// GGUF's header: the internet, and the cluster's own network, where its
// mirror is, but not instance metadata or anything link-local.
var ControlPlane = Policy{Private: true, Loopback: true}
+69
View File
@@ -0,0 +1,69 @@
package safedial
import (
"errors"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
)
func TestWhatEachPolicyRefuses(t *testing.T) {
cases := []struct {
addr string
internet, control, private bool // blocked under Policy{}, ControlPlane, all allowed
}{
{"203.0.113.7", false, false, false},
{"2606:4700::1111", false, false, false},
{"169.254.169.254", true, true, false},
{"fe80::1", true, true, false},
{"127.0.0.1", true, false, false},
{"::1", true, false, false},
{"10.0.0.146", true, false, false},
{"fd00::5", true, false, false},
{"100.64.1.1", true, false, false},
{"0.0.0.0", true, true, false},
// An IPv4 address carried in an IPv6 one is judged as itself.
{"::ffff:169.254.169.254", true, true, false},
{"64:ff9b::a9fe:a9fe", true, true, false},
{"64:ff9b::cb00:7107", false, false, false},
{"2002:a9fe:a9fe::1", true, true, false},
{"2002:0a00:0001::1", true, false, false},
{"::a9fe:a9fe", true, true, false},
}
all := Policy{Private: true, Loopback: true, LinkLocal: true}
for _, c := range cases {
a := netip.MustParseAddr(c.addr)
if got := Blocked(a, Policy{}); got != c.internet {
t.Errorf("%s under the internet-only policy: blocked=%v", c.addr, got)
}
if got := Blocked(a, ControlPlane); got != c.control {
t.Errorf("%s under the control plane's: blocked=%v", c.addr, got)
}
if got := Blocked(a, all); got != c.private {
t.Errorf("%s with everything allowed: blocked=%v", c.addr, got)
}
}
}
func TestTheAddressDialledIsWhatIsChecked(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
defer srv.Close()
if _, err := Client(Policy{}, 0).Get(srv.URL); !errors.Is(err, ErrBlocked) {
t.Errorf("loopback reached under the internet-only policy: %v", err)
}
resp, err := Client(ControlPlane, 0).Get(srv.URL)
if err != nil {
t.Fatalf("the control plane's own machine: %v", err)
}
resp.Body.Close()
// A redirect to a scheme that reads the disk is refused.
redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "file:///etc/passwd", http.StatusFound)
}))
defer redir.Close()
if _, err := Client(ControlPlane, 0).Get(redir.URL); err == nil {
t.Error("followed a redirect to file://")
}
}
+5 -1
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/safedial"
)
// Check asks whether a model's weights are actually there.
@@ -36,7 +38,9 @@ func Check(ctx context.Context, source string) (size int64, err error) {
if err != nil {
return 0, err
}
resp, err := http.DefaultClient.Do(req)
// Not anything link-local: instance metadata answering a HEAD would be
// read through this, by whoever can add a model.
resp, err := safedial.Client(safedial.ControlPlane, 0).Do(req)
if err != nil {
return 0, fmt.Errorf("cannot reach %s: %w", url, err)
}
+2 -1
View File
@@ -19,6 +19,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/safedial"
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
@@ -799,7 +800,7 @@ func (p *AccountPages) openRepo(r *http.Request, d *adminMirrorData, repo string
// kindOf reads what a GGUF file is; a variable so tests need no registry.
var kindOf = func(ctx context.Context, url string) (gguf.Header, error) {
return gguf.KindFromURL(ctx, nil, url)
return gguf.KindFromURL(ctx, safedial.Client(safedial.ControlPlane, 30*time.Second), url)
}
// probeKinds reads the start of each single-file GGUF set, to tell LoRA