diff --git a/agent/app/provider/catalog.go b/agent/app/provider/catalog.go index 68a9e1425..a5a46d98b 100644 --- a/agent/app/provider/catalog.go +++ b/agent/app/provider/catalog.go @@ -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)) { diff --git a/agent/app/provider/discover.go b/agent/app/provider/discover.go index dff67c517..909784adf 100644 --- a/agent/app/provider/discover.go +++ b/agent/app/provider/discover.go @@ -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 } diff --git a/agent/app/provider/verify.go b/agent/app/provider/verify.go index af320fb95..0fedb6f44 100644 --- a/agent/app/provider/verify.go +++ b/agent/app/provider/verify.go @@ -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 { diff --git a/agent/app/service/mcp_connection.go b/agent/app/service/mcp_connection.go new file mode 100644 index 000000000..35cfc0988 --- /dev/null +++ b/agent/app/service/mcp_connection.go @@ -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) +} diff --git a/agent/app/service/mcp_server.go b/agent/app/service/mcp_server.go index 4e6abb303..35410e48e 100644 --- a/agent/app/service/mcp_server.go +++ b/agent/app/service/mcp_server.go @@ -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() diff --git a/agent/utils/aihttp/client.go b/agent/utils/aihttp/client.go new file mode 100644 index 000000000..77b2106ee --- /dev/null +++ b/agent/utils/aihttp/client.go @@ -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 +} diff --git a/agent/utils/webhook_sender/pinned_transport.go b/agent/utils/webhook_sender/pinned_transport.go new file mode 100644 index 000000000..9102b4229 --- /dev/null +++ b/agent/utils/webhook_sender/pinned_transport.go @@ -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 +} diff --git a/frontend/src/views/ai/model/account-form/index.vue b/frontend/src/views/ai/model/account-form/index.vue index 61c26a167..ccba505ac 100644 --- a/frontend/src/views/ai/model/account-form/index.vue +++ b/frontend/src/views/ai/model/account-form/index.vue @@ -147,6 +147,7 @@