fix: guard AI endpoint probes and discovery (#14347)

This commit is contained in:
蘭 authored and GitHub committed 2026-11-08 22:40:28 +08:00
1 parent 038fe1625a
commit f3eafd9a2e
8 files changed
+372 -110

No files matched your search

+3 -1
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"net/url"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
)
type APIConfig struct {
@@ -373,7 +375,7 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
return "", fmt.Errorf("base url is required")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
if err != nil || aihttp.ValidateURL(baseURL) != nil {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
+19 -7
View File
@@ -6,34 +6,46 @@ import (
"net/http"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
)
func DiscoverModels(baseURL, apiKey string) ([]string, error) {
req, err := http.NewRequest(http.MethodGet, buildModelDiscoveryURL(baseURL), nil)
if err != nil {
return nil, err
return nil, aihttp.ErrTarget
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(req)
resp, err := aihttp.NewClient(defaultVerifyTimeout).Do(req)
if err != nil {
return nil, aihttp.SafeError(err)
}
defer resp.Body.Close()
body, err := aihttp.ReadResponseBody(resp.Body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("request failed: %s", resp.Status)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("request failed: status %d", resp.StatusCode)
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
if err := json.Unmarshal(body, &payload); err != nil {
return nil, aihttp.ErrResponse
}
if len(payload.Data) > aihttp.MaxDiscoveredModels {
return nil, fmt.Errorf("model discovery exceeded the model limit")
}
models := make([]string, 0, len(payload.Data))
seen := make(map[string]struct{}, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if len(id) > 512 {
return nil, aihttp.ErrResponse
}
if id == "" {
continue
}
+10 -29
View File
@@ -5,12 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
)
type VerifyRequest struct {
@@ -20,13 +20,6 @@ type VerifyRequest struct {
Body []byte
}
type verifyErrorResponse struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
const (
defaultVerifyTimeout = 30 * time.Second
defaultVerifyMaxTokens = 16
@@ -36,22 +29,22 @@ func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) e
req := BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model)
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
if err != nil {
return err
return buserr.WithErr("ErrAgentAccountUnavailable", aihttp.ErrTarget)
}
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
httpReq.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(httpReq)
resp, err := aihttp.NewClient(defaultVerifyTimeout).Do(httpReq)
if err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err)
return buserr.WithErr("ErrAgentAccountUnavailable", aihttp.SafeError(err))
}
defer resp.Body.Close()
body, readErr := aihttp.ReadResponseBody(resp.Body)
if readErr != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if readErr != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
}
return buserr.WithErr("ErrAgentAccountUnavailable", errors.New(verifyHTTPError(resp.StatusCode, body)))
}
return nil
@@ -156,20 +149,8 @@ func imageVerifyURL(provider, baseURL, endpoint string) string {
return baseURL + endpoint
}
func verifyHTTPError(statusCode int, body []byte) string {
message := strings.TrimSpace(string(body))
var payload verifyErrorResponse
if err := json.Unmarshal(body, &payload); err == nil {
if value := strings.TrimSpace(payload.Error.Message); value != "" {
message = value
} else if value := strings.TrimSpace(payload.Message); value != "" {
message = value
}
}
if message == "" {
return fmt.Sprintf("validation request returned status %d", statusCode)
}
return message
func verifyHTTPError(statusCode int, _ []byte) string {
return fmt.Sprintf("validation request returned status %d", statusCode)
}
func mustJSON(value interface{}) []byte {
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
)
const (
mcpOutputTransportSSE = "sse"
mcpOutputTransportStreamableHTTP = "streamableHttp"
)
func probeMcpConnection(endpoint, outputTransport, protocolVersion string) (bool, string) {
client := aihttp.NewClient(10 * time.Second)
if outputTransport == mcpOutputTransportSSE {
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return false, aihttp.ErrTarget.Error()
}
response, err := client.Do(request)
if err != nil {
return false, aihttp.SafeError(err).Error()
}
defer response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 && strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
return true, "ok"
}
if _, err := aihttp.ReadResponseBody(response.Body); err != nil {
return false, aihttp.SafeError(err).Error()
}
return false, fmt.Sprintf("unexpected response: status=%d", response.StatusCode)
}
payload := map[string]interface{}{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": map[string]interface{}{
"protocolVersion": protocolVersion,
"capabilities": map[string]interface{}{},
"clientInfo": map[string]string{"name": "1Panel", "version": "1.0.0"},
},
}
body, _ := json.Marshal(payload)
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return false, aihttp.ErrTarget.Error()
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
response, err := client.Do(request)
if err != nil {
return false, aihttp.SafeError(err).Error()
}
defer response.Body.Close()
return handleMcpConnectionHTTPResponse(response)
}
func handleMcpConnectionHTTPResponse(response *http.Response) (bool, string) {
if response.StatusCode >= 200 && response.StatusCode < 300 && strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
return true, "ok"
}
if _, err := aihttp.ReadResponseBody(response.Body); err != nil {
return false, aihttp.SafeError(err).Error()
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return true, "ok"
}
return false, fmt.Sprintf("endpoint returned status %d", response.StatusCode)
}
+4 -73
View File
@@ -1,17 +1,12 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
@@ -40,11 +35,9 @@ import (
type McpServerService struct{}
const (
defaultMcpGatewayImageNpx = "supercorp/supergateway:3.4.3"
defaultMcpGatewayImageUvx = "supercorp/supergateway:uvx"
defaultMcpProtocolVersion = "2025-06-18"
mcpOutputTransportSSE = "sse"
mcpOutputTransportStreamableHTTP = "streamableHttp"
defaultMcpGatewayImageNpx = "supercorp/supergateway:3.4.3"
defaultMcpGatewayImageUvx = "supercorp/supergateway:uvx"
defaultMcpProtocolVersion = "2025-06-18"
)
type IMcpServerService interface {
@@ -352,72 +345,10 @@ func (m McpServerService) TestConnection(req request.McpServerConnectionTest) (r
return res, nil
}
client := &http.Client{Timeout: 10 * time.Second}
if mcpServer.OutputTransport == mcpOutputTransportSSE {
httpReq, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
res.Message = err.Error()
return res, nil
}
httpRes, err := client.Do(httpReq)
if err != nil {
res.Message = err.Error()
return res, nil
}
defer httpRes.Body.Close()
contentType := httpRes.Header.Get("Content-Type")
if httpRes.StatusCode >= 200 && httpRes.StatusCode < 300 && strings.Contains(contentType, "text/event-stream") {
res.Success = true
res.Message = "ok"
return res, nil
}
res.Message = fmt.Sprintf("unexpected response: status=%d content-type=%s", httpRes.StatusCode, contentType)
return res, nil
}
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]interface{}{
"protocolVersion": mcpServer.ProtocolVersion,
"capabilities": map[string]interface{}{},
"clientInfo": map[string]string{
"name": "1Panel",
"version": "1.0.0",
},
},
}
body, _ := json.Marshal(payload)
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
res.Message = err.Error()
return res, nil
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
httpRes, err := client.Do(httpReq)
if err != nil {
res.Message = err.Error()
return res, nil
}
defer httpRes.Body.Close()
res.Success, res.Message = handleMcpConnectionHTTPResponse(httpRes)
res.Success, res.Message = probeMcpConnection(endpoint, mcpServer.OutputTransport, mcpServer.ProtocolVersion)
return res, nil
}
func handleMcpConnectionHTTPResponse(httpRes *http.Response) (bool, string) {
if httpRes.StatusCode >= 200 && httpRes.StatusCode < 300 {
return true, "ok"
}
respBody, _ := io.ReadAll(httpRes.Body)
message := strings.TrimSpace(string(respBody))
if message == "" {
message = httpRes.Status
}
return false, message
}
func (m McpServerService) GetBindDomain() (response.McpBindDomainRes, error) {
var res response.McpBindDomainRes
websiteID := GetWebsiteID()
+218
View File
@@ -0,0 +1,218 @@
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, ErrTarget
}
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, ErrRequest
}
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 SafeError(err error) error {
if errors.Is(err, ErrTarget) {
return ErrTarget
}
if errors.Is(err, ErrResponseTooLarge) {
return ErrResponseTooLarge
}
if errors.Is(err, ErrResponse) {
return ErrResponse
}
return ErrRequest
}
func ReadResponseBody(body io.Reader) ([]byte, error) {
content, err := io.ReadAll(io.LimitReader(body, MaxResponseBytes+1))
if err != nil {
return nil, ErrResponse
}
if len(content) > MaxResponseBytes {
return nil, ErrResponseTooLarge
}
return content, nil
}
@@ -0,0 +1,44 @@
package webhook_sender
import (
"errors"
"io"
"net"
"net/http"
)
func RoundTripPinned(request *http.Request, addresses []net.IP, input *http.Transport) (*http.Response, error) {
if request == nil || request.URL == nil || len(addresses) == 0 || addresses[0] == nil {
return nil, errors.New("invalid pinned HTTP target")
}
originalURL := cloneURL(request.URL)
pinned := request.Clone(request.Context())
pinned.URL = cloneURL(originalURL)
pinned.URL.Host = pinnedHost(addresses[0], originalURL.Port())
pinned.Host = originalURL.Host
transport := systemTLSTransport(input, originalURL.Hostname(), originalURL, addresses)
transport.DisableKeepAlives = true
var roundTripper http.RoundTripper = transport
if originalURL.Scheme == "http" {
roundTripper = &pinnedHTTPProxyRoundTripper{transport: transport, originalHost: originalURL.Host}
}
response, err := roundTripper.RoundTrip(pinned)
if err != nil {
transport.CloseIdleConnections()
return nil, err
}
response.Request = request
response.Body = &pinnedResponseBody{ReadCloser: response.Body, transport: transport}
return response, nil
}
type pinnedResponseBody struct {
io.ReadCloser
transport *http.Transport
}
func (body *pinnedResponseBody) Close() error {
err := body.ReadCloser.Close()
body.transport.CloseIdleConnections()
return err
}
@@ -147,6 +147,7 @@
</el-radio-group>
<el-button
v-if="supportsModelDiscovery && modelConfigMode === 'discover'"
v-permission="'ai_model_manage'"
type="primary"
plain
:loading="discovering"