Files
LocalAI/pkg/web/safehttp/safehttp.go
T
Ettore Di Giacinto 59e3f01739 fix(safehttp): block more cloud metadata addresses and NAT64
Add the Azure wire server (168.63.129.16), Oracle (192.0.0.192) and
Alibaba (100.100.100.200) addresses to the metadata list. The Azure one
is public, so web_fetch reached it. Check the IPv4 address inside a
64:ff9b::/96 address too, so private addresses and metadata addresses
cannot be reached through NAT64 when private addresses are allowed.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 08:39:12 +00:00

405 lines
12 KiB
Go

// Package safehttp fetches public web pages for a model. A model chooses the
// URL, so every request is treated as hostile: the URL, every redirect target
// and every address a name resolves to are checked, and the connection goes
// to the address that was checked, so a second DNS answer cannot move it
// inside the network (DNS rebinding).
package safehttp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// Errors of a fetch. The tools turn them into text for the model, so each
// says what was wrong without echoing internal detail.
var (
ErrURL = errors.New("the URL is not valid")
ErrScheme = errors.New("only http and https URLs can be fetched")
ErrUserinfo = errors.New("URLs with a user name or password are not fetched")
ErrPort = errors.New("this port is not allowed")
ErrBlockedAddress = errors.New("the address is inside a private network or reserved")
ErrTooManyRedirects = errors.New("too many redirects")
ErrContentType = errors.New("unsupported type")
ErrTimeout = errors.New("the server did not answer in time")
ErrStatus = errors.New("the server answered with an error")
)
// Policy is what a client may reach and how much it reads.
type Policy struct {
// AllowPrivate lets requests reach private, loopback and link-local
// addresses. The cloud metadata addresses and the unspecified address
// stay blocked.
AllowPrivate bool
// Ports are the ports a URL may name. Empty means 80 and 443.
Ports []int
// DenyAddrs lists addresses refused even when public (the addresses of
// this server, see SelfAddrs). Nil refuses none.
DenyAddrs func() []netip.Prefix
MaxURLBytes int
MaxRedirects int
MaxBodyBytes int64
Timeout time.Duration
UserAgent string
}
// Defaults of a Policy field left at its zero value.
const (
DefaultMaxURLBytes = 2048
DefaultMaxRedirects = 5
DefaultMaxBodyBytes = 2 << 20
DefaultTimeout = 15 * time.Second
DefaultUserAgent = "LocalAI (web_fetch; +https://localai.io)"
)
func (p Policy) withDefaults() Policy {
if len(p.Ports) == 0 {
p.Ports = []int{80, 443}
}
if p.MaxURLBytes <= 0 {
p.MaxURLBytes = DefaultMaxURLBytes
}
if p.MaxRedirects <= 0 {
p.MaxRedirects = DefaultMaxRedirects
}
if p.MaxBodyBytes <= 0 {
p.MaxBodyBytes = DefaultMaxBodyBytes
}
if p.Timeout <= 0 {
p.Timeout = DefaultTimeout
}
if p.UserAgent == "" {
p.UserAgent = DefaultUserAgent
}
return p
}
// Resolver resolves a host name. *net.Resolver implements it; tests pass a stub.
type Resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
// Client fetches URLs under a Policy. It keeps no cookies and sends no
// credentials. It is safe for concurrent use.
type Client struct {
p Policy
g *Guard
hc *http.Client
}
// New returns a client. A nil resolver uses net.DefaultResolver.
func New(p Policy, r Resolver) *Client {
p = p.withDefaults()
c := &Client{p: p, g: NewGuard(GuardPolicy{AllowPrivate: p.AllowPrivate, Ports: p.Ports, DenyAddrs: p.DenyAddrs}, r)}
tr := &http.Transport{
// No proxy: a proxy would resolve the name again, after the check.
Proxy: nil,
// The guard's dial: resolve once, check every answer, connect to the
// checked address.
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return c.g.DialContext(ctx, network, addr)
},
ForceAttemptHTTP2: true,
MaxIdleConns: 16,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: p.Timeout,
MaxResponseHeaderBytes: 64 << 10,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
c.hc = &http.Client{
Transport: tr,
Jar: nil,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) > p.MaxRedirects {
return ErrTooManyRedirects
}
if _, err := c.CheckURL(req.URL.String()); err != nil {
return err
}
// Only the headers this client sets travel with a redirect.
for k := range req.Header {
if k != "User-Agent" && k != "Accept" {
req.Header.Del(k)
}
}
return nil
},
}
return c
}
// Policy returns the policy in force, with its defaults.
func (c *Client) Policy() Policy { return c.p }
// Response is a fetched body.
type Response struct {
FinalURL *url.URL
// StatusCode is the 2xx status of the answer. A search page that answers
// 202 instead of 200 is often a bot check, not results.
StatusCode int
// ContentType is the media type only, in lower case.
ContentType string
// Charset is the charset parameter of the Content-Type header, if any.
Charset string
Body []byte
// Truncated says that the body was longer than MaxBodyBytes.
Truncated bool
}
// Get fetches raw. accept lists the media types the caller can read; an entry
// may be "type/*" or "application/*+json". Another type is ErrContentType.
func (c *Client) Get(ctx context.Context, raw string, accept []string) (*Response, error) {
u, err := c.CheckURL(raw)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, c.p.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, ErrURL
}
req.Header.Set("User-Agent", c.p.UserAgent)
if len(accept) > 0 {
req.Header.Set("Accept", strings.Join(accept, ", ")+", */*;q=0.1")
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, classify(ctx, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &StatusError{Code: resp.StatusCode, Status: resp.Status}
}
ct := resp.Header.Get("Content-Type")
media, charset := "application/octet-stream", ""
if ct != "" {
if m, params, err := mime.ParseMediaType(ct); err == nil {
media, charset = strings.ToLower(m), params["charset"]
}
}
if len(accept) > 0 && !Accepts(accept, media) {
return nil, fmt.Errorf("%w: %s", ErrContentType, media)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, c.p.MaxBodyBytes+1))
if err != nil {
return nil, classify(ctx, err)
}
out := &Response{FinalURL: resp.Request.URL, StatusCode: resp.StatusCode, ContentType: media, Charset: charset, Body: body}
if int64(len(body)) > c.p.MaxBodyBytes {
out.Body, out.Truncated = body[:c.p.MaxBodyBytes], true
}
return out, nil
}
// StatusError is an answer outside 2xx. It is ErrStatus for errors.Is, and
// keeps the code so a caller can tell a refusal (403, 429) from a failure.
type StatusError struct {
Code int
Status string
}
func (e *StatusError) Error() string { return ErrStatus.Error() + ": " + e.Status }
// Unwrap makes errors.Is(err, ErrStatus) hold.
func (e *StatusError) Unwrap() error { return ErrStatus }
// Accepts says whether media matches one entry of accept.
func Accepts(accept []string, media string) bool {
for _, a := range accept {
switch {
case a == media:
return true
case strings.HasSuffix(a, "/*") && strings.HasPrefix(media, strings.TrimSuffix(a, "*")):
return true
case strings.HasPrefix(a, "application/*+"):
if strings.HasPrefix(media, "application/") && strings.HasSuffix(media, strings.TrimPrefix(a, "application/*")) {
return true
}
}
}
return false
}
// classify maps a transport error to the errors of this package.
func classify(ctx context.Context, err error) error {
for _, e := range []error{ErrURL, ErrScheme, ErrUserinfo, ErrPort, ErrBlockedAddress, ErrTooManyRedirects} {
if errors.Is(err, e) {
return e
}
}
var ne net.Error
if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil || (errors.As(err, &ne) && ne.Timeout()) {
return ErrTimeout
}
var dns *net.DNSError
if errors.As(err, &dns) {
return fmt.Errorf("%w: the host name does not resolve", ErrURL)
}
return fmt.Errorf("the page could not be fetched: %w", unwrapURLError(err))
}
func unwrapURLError(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
// CheckURL parses raw and applies the URL rules: length, scheme, no
// userinfo, an allowed port and a host that is a name or a canonical IP
// literal. A literal address is checked here too; a name is checked when it
// is dialed. The fragment is dropped.
func (c *Client) CheckURL(raw string) (*url.URL, error) {
if len(raw) == 0 || len(raw) > c.p.MaxURLBytes {
return nil, fmt.Errorf("%w: it must be 1 to %d bytes long", ErrURL, c.p.MaxURLBytes)
}
for _, r := range raw {
if r < 0x21 || r == 0x7f {
return nil, fmt.Errorf("%w: it contains a space or a control character", ErrURL)
}
}
u, err := url.Parse(raw)
if err != nil {
return nil, ErrURL
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return nil, ErrScheme
}
if u.User != nil {
return nil, ErrUserinfo
}
if u.Opaque != "" {
return nil, ErrURL
}
if u.Hostname() == "" {
return nil, fmt.Errorf("%w: it has no host", ErrURL)
}
port := 80
if strings.EqualFold(u.Scheme, "https") {
port = 443
}
if ps := u.Port(); ps != "" {
n, err := strconv.Atoi(ps)
if err != nil || n < 1 || n > 65535 {
return nil, ErrURL
}
port = n
}
if _, err := c.g.CheckHost(u.Hostname(), port); err != nil {
return nil, err
}
u.Fragment, u.RawFragment = "", ""
return u, nil
}
// looksNumeric says whether the last label of host is a number in any base,
// which makes a URL parser read the whole host as an IPv4 address.
func looksNumeric(host string) bool {
labels := strings.Split(host, ".")
last := labels[len(labels)-1]
if last == "" {
return false
}
if strings.HasPrefix(last, "0x") {
last = last[2:]
if last == "" {
return true
}
for _, r := range last {
if !strings.ContainsRune("0123456789abcdef", r) {
return false
}
}
return true
}
for _, r := range last {
if r < '0' || r > '9' {
return false
}
}
return true
}
// metadata are the cloud instance metadata addresses. They stay blocked when
// private addresses are allowed: they hand out credentials.
var metadata = []netip.Addr{
netip.MustParseAddr("169.254.169.254"),
netip.MustParseAddr("169.254.170.2"),
netip.MustParseAddr("fd00:ec2::254"),
netip.MustParseAddr("168.63.129.16"), // Azure wire server, a public address
netip.MustParseAddr("192.0.0.192"), // Oracle Cloud
netip.MustParseAddr("100.100.100.200"), // Alibaba Cloud
}
var (
sixToFour = netip.MustParsePrefix("2002::/16")
nat64 = netip.MustParsePrefix("64:ff9b::/96")
teredo = netip.MustParsePrefix("2001::/32")
)
// Allowed says whether a fetch may connect to a. Without allowPrivate only
// public addresses pass. IPv4 addresses embedded in IPv6 (mapped, compatible,
// NAT64, 6to4) are checked as IPv4.
func Allowed(a netip.Addr, allowPrivate bool) bool {
if !a.IsValid() || a.Zone() != "" {
return false
}
a = a.Unmap()
if a.Is6() {
b := a.As16()
// IPv4-compatible (::a.b.c.d, deprecated) and the 6to4 prefix carry
// an IPv4 address that some stacks route to.
if isZero(b[:12]) && !a.IsLoopback() && !a.IsUnspecified() {
return Allowed(netip.AddrFrom4([4]byte(b[12:16])), allowPrivate)
}
if sixToFour.Contains(a) {
return Allowed(netip.AddrFrom4([4]byte(b[2:6])), allowPrivate)
}
// NAT64 hands the IPv4 address in the last 32 bits to a gateway, so
// 64:ff9b::a9fe:a9fe reaches the metadata address.
if nat64.Contains(a) {
return Allowed(netip.AddrFrom4([4]byte(b[12:16])), allowPrivate)
}
if teredo.Contains(a) && !allowPrivate {
return false
}
}
if slices.Contains(metadata, a) {
return false
}
if a.IsUnspecified() {
return false
}
if allowPrivate {
return true
}
return httpclient.IsPublicIP(net.IP(a.AsSlice()))
}
func isZero(b []byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}