mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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>
405 lines
12 KiB
Go
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
|
|
}
|