mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-13 08:06:06 +00:00
206 lines
5.4 KiB
Go
206 lines
5.4 KiB
Go
package aihttp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
|
|
"golang.org/x/net/idna"
|
|
)
|
|
|
|
const MaxResponseBytes = 1024 * 1024
|
|
const MaxDiscoveredModels = 4096
|
|
|
|
var (
|
|
ErrTarget = errors.New("AI endpoint is invalid or blocked by outbound policy")
|
|
ErrRequest = errors.New("AI endpoint request failed")
|
|
ErrResponse = errors.New("AI endpoint returned an invalid response")
|
|
ErrResponseTooLarge = errors.New("AI endpoint response exceeded the size limit")
|
|
)
|
|
|
|
type ipResolver interface {
|
|
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
|
|
}
|
|
|
|
type guardedTransport struct {
|
|
resolver ipResolver
|
|
transport *http.Transport
|
|
}
|
|
|
|
func NewClient(timeout time.Duration) *http.Client {
|
|
return newClient(timeout, nil, nil)
|
|
}
|
|
|
|
func newClient(timeout time.Duration, resolver ipResolver, transport *http.Transport) *http.Client {
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &guardedTransport{resolver: resolver, transport: transport},
|
|
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
|
if len(via) >= 3 || len(via) == 0 || !sameOrigin(via[0].URL, next.URL) {
|
|
return ErrTarget
|
|
}
|
|
return ValidateURL(next.URL.String())
|
|
},
|
|
}
|
|
}
|
|
|
|
func (guard *guardedTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
if request == nil || request.URL == nil {
|
|
return nil, ErrTarget
|
|
}
|
|
canonical, err := normalizeTargetURL(request.URL.String())
|
|
if err != nil {
|
|
return nil, ErrTarget
|
|
}
|
|
hostname := canonical.Hostname()
|
|
var addresses []net.IP
|
|
if literal := net.ParseIP(hostname); literal != nil {
|
|
addresses = append(addresses, literal)
|
|
} else {
|
|
resolver := guard.resolver
|
|
if resolver == nil {
|
|
resolver = net.DefaultResolver
|
|
}
|
|
resolved, err := resolver.LookupIPAddr(request.Context(), hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range resolved {
|
|
if item.Zone != "" || !allowedIP(item.IP) {
|
|
return nil, ErrTarget
|
|
}
|
|
addresses = append(addresses, item.IP)
|
|
}
|
|
}
|
|
if len(addresses) == 0 {
|
|
return nil, ErrTarget
|
|
}
|
|
for _, address := range addresses {
|
|
if !allowedIP(address) {
|
|
return nil, ErrTarget
|
|
}
|
|
}
|
|
normalized := request.Clone(request.Context())
|
|
normalized.URL = canonical
|
|
normalized.Host = canonical.Host
|
|
response, err := webhook_sender.RoundTripPinned(normalized, addresses, guard.transport)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func ValidateURL(rawURL string) error {
|
|
_, err := normalizeTargetURL(rawURL)
|
|
return err
|
|
}
|
|
|
|
func normalizeTargetURL(rawURL string) (*url.URL, error) {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" ||
|
|
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return nil, ErrTarget
|
|
}
|
|
hostname := strings.TrimSuffix(parsed.Hostname(), ".")
|
|
if hostname == "" || strings.Contains(hostname, "%") {
|
|
return nil, ErrTarget
|
|
}
|
|
if net.ParseIP(hostname) == nil {
|
|
hostname, err = idna.Lookup.ToASCII(hostname)
|
|
if err != nil || hostname == "" {
|
|
return nil, ErrTarget
|
|
}
|
|
}
|
|
hostname = strings.ToLower(hostname)
|
|
if address := net.ParseIP(hostname); address != nil {
|
|
if !allowedIP(address) {
|
|
return nil, ErrTarget
|
|
}
|
|
hostname = address.String()
|
|
}
|
|
if port := parsed.Port(); port != "" {
|
|
parsed.Host = net.JoinHostPort(hostname, port)
|
|
} else if strings.Contains(hostname, ":") {
|
|
parsed.Host = "[" + hostname + "]"
|
|
} else {
|
|
parsed.Host = hostname
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
var blockedPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/8"),
|
|
netip.MustParsePrefix("240.0.0.0/4"),
|
|
netip.MustParsePrefix("fd00:ec2::/64"),
|
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
|
netip.MustParsePrefix("2001::/32"),
|
|
}
|
|
|
|
func allowedIP(ip net.IP) bool {
|
|
address, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return false
|
|
}
|
|
address = address.Unmap()
|
|
if address.IsUnspecified() || address.IsMulticast() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
|
|
address == netip.MustParseAddr("100.100.100.200") {
|
|
return false
|
|
}
|
|
if netip.MustParsePrefix("::/96").Contains(address) && address != netip.IPv6Loopback() {
|
|
return false
|
|
}
|
|
if netip.MustParsePrefix("64:ff9b::/96").Contains(address) || netip.MustParsePrefix("::ffff:0:0:0/96").Contains(address) {
|
|
value := address.As16()
|
|
return allowedIP(net.IPv4(value[12], value[13], value[14], value[15]))
|
|
}
|
|
if netip.MustParsePrefix("2002::/16").Contains(address) {
|
|
value := address.As16()
|
|
return allowedIP(net.IPv4(value[2], value[3], value[4], value[5]))
|
|
}
|
|
for _, prefix := range blockedPrefixes {
|
|
if prefix.Contains(address) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameOrigin(first, second *url.URL) bool {
|
|
if first == nil || second == nil {
|
|
return false
|
|
}
|
|
first, firstErr := normalizeTargetURL(first.String())
|
|
second, secondErr := normalizeTargetURL(second.String())
|
|
if firstErr != nil || secondErr != nil || first.Scheme != second.Scheme || first.Hostname() != second.Hostname() {
|
|
return false
|
|
}
|
|
port := func(value *url.URL) string {
|
|
if value.Port() != "" {
|
|
return value.Port()
|
|
}
|
|
if value.Scheme == "https" {
|
|
return "443"
|
|
}
|
|
return "80"
|
|
}
|
|
return port(first) == port(second)
|
|
}
|
|
|
|
func ReadResponseBody(body io.Reader) ([]byte, error) {
|
|
content, err := io.ReadAll(io.LimitReader(body, MaxResponseBytes+1))
|
|
if err != nil {
|
|
return nil, SafeError(err)
|
|
}
|
|
if len(content) > MaxResponseBytes {
|
|
return nil, ErrResponseTooLarge
|
|
}
|
|
return content, nil
|
|
}
|