mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-10-12 02:16:39 +00:00
feat(mcp): allow private MCP server hosts via deployment allowlist (#20571)
This commit is contained in:
@@ -68,6 +68,7 @@ const (
|
||||
EnvRAGFlowTTSCacheTTLSeconds = "RAGFLOW_TTS_CACHE_TTL_SECONDS"
|
||||
EnvRerankTokenLimitMode = "RERANK_TOKEN_LIMIT_MODE"
|
||||
EnvComponentExecTimeout = "COMPONENT_EXEC_TIMEOUT"
|
||||
EnvMCPAllowPrivateHosts = "RAGFLOW_MCP_ALLOW_PRIVATE_HOSTS"
|
||||
EnvDocEngine = "DOC_ENGINE"
|
||||
EnvMaxFileNumPerUser = "MAX_FILE_NUM_PER_USER"
|
||||
EnvMaxContentLength = "MAX_CONTENT_LENGTH"
|
||||
|
||||
@@ -105,7 +105,8 @@ type Environments struct {
|
||||
MinerUBackend string `mapstructure:"mineru_backend"` // MINERU_BACKEND
|
||||
TavilyAPIKey string `mapstructure:"tavily_api_key"`
|
||||
QueritAPIKey string `mapstructure:"querit_api_key"`
|
||||
KeenableAPIURL string `mapstructure:"keenable_api_url"` // KEENABLE_API_URL
|
||||
KeenableAPIURL string `mapstructure:"keenable_api_url"` // KEENABLE_API_URL
|
||||
MCPAllowPrivateHosts bool `mapstructure:"mcp_allow_private_hosts"` // RAGFLOW_MCP_ALLOW_PRIVATE_HOSTS
|
||||
}
|
||||
|
||||
type DefaultSuperUser struct {
|
||||
@@ -286,6 +287,11 @@ func (c *Config) GetEnvironments() error {
|
||||
c.environments.HomePath = homePath
|
||||
}
|
||||
|
||||
mcpAllowPrivateHosts := common.GetEnvSmall(common.EnvMCPAllowPrivateHosts)
|
||||
if mcpAllowPrivateHosts != "" {
|
||||
c.environments.MCPAllowPrivateHosts = mcpAllowPrivateHosts == "true" || mcpAllowPrivateHosts == "1" || mcpAllowPrivateHosts == "yes"
|
||||
}
|
||||
|
||||
userProfile := common.GetEnv(common.EnvUserProfile)
|
||||
if userProfile != "" {
|
||||
c.environments.UserProfile = userProfile
|
||||
|
||||
@@ -824,7 +824,7 @@ func (s *MCPService) TestServer(ctx context.Context, tenantID, mcpID string, req
|
||||
// ErrMCPInvalidURL data errors instead of being swallowed inside the
|
||||
// generic FetchTools error and re-classified by the handler as a 500.
|
||||
// FetchTools repeats the check internally; the second call is cheap.
|
||||
if _, _, err := common.AssertURLSafe(req.URL); err != nil {
|
||||
if _, _, err := utility.AssertMCPURLSafe(req.URL); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrMCPInvalidURL, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ragflow/internal/common"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -73,7 +72,7 @@ func CallTool(ctx context.Context, opts CallOptions) (*CallResult, error) {
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 10 * time.Second
|
||||
}
|
||||
hostname, resolvedIP, err := common.AssertURLSafe(opts.URL)
|
||||
hostname, resolvedIP, err := AssertMCPURLSafe(opts.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"ragflow/internal/common"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -93,7 +92,7 @@ func FetchTools(ctx context.Context, opts FetchOptions) ([]Tool, error) {
|
||||
opts.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
hostname, resolvedIP, err := common.AssertURLSafe(opts.URL)
|
||||
hostname, resolvedIP, err := AssertMCPURLSafe(opts.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,8 +452,10 @@ func requestSSE(ctx context.Context, endpoint string, headers map[string]string,
|
||||
// the SSRF guard against the resolved URL, and — when the host
|
||||
// differs from the original SSE host — swap in a fresh pinned
|
||||
// client so the dial-time IP override still applies.
|
||||
// Private targets are accepted only when they match the configured
|
||||
// SSE endpoint host, preventing unrelated internal redirection.
|
||||
postClient := client
|
||||
if postHost, postIP, vErr := common.AssertURLSafe(postURL); vErr != nil {
|
||||
if postHost, postIP, vErr := AssertMCPURLSafeSameOrigin(postURL, endpoint); vErr != nil {
|
||||
return nil, vErr
|
||||
} else if u, perr := url.Parse(postURL); perr == nil && u.Hostname() != "" {
|
||||
if u.Hostname() != originalHost(endpoint) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package utility
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"ragflow/internal/common"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AssertMCPURLSafe validates an MCP server URL. It delegates to
|
||||
// common.AssertURLSafe by default. When the deployment sets
|
||||
// RAGFLOW_MCP_ALLOW_PRIVATE_HOSTS=true (a deployment-level operator decision),
|
||||
// the public-routability requirement is relaxed so self-hosted MCP servers on
|
||||
// private/docker networks work — scheme validation, DNS resolution, and
|
||||
// connection pinning to the resolved IP still apply.
|
||||
//
|
||||
// Use this variant for operator-configured URLs (MCP server registrations and
|
||||
// direct tool calls). For URLs advertised by an SSE endpoint event, use
|
||||
// AssertMCPURLSafeSameOrigin so a malicious server cannot redirect POSTs to an
|
||||
// unrelated internal host.
|
||||
func AssertMCPURLSafe(rawURL string) (hostname, resolvedIP string, err error) {
|
||||
hostname, resolvedIP, err = common.AssertURLSafe(rawURL)
|
||||
if err == nil || !mcpPrivateHostsAllowed() {
|
||||
return hostname, resolvedIP, err
|
||||
}
|
||||
strictErr := err
|
||||
if serr := common.AssertURLSchemeSafe(rawURL); serr != nil {
|
||||
return "", "", strictErr
|
||||
}
|
||||
parsed, perr := url.Parse(strings.TrimSpace(rawURL))
|
||||
if perr != nil {
|
||||
return "", "", strictErr
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
addresses, lerr := common.LookupHost(host)
|
||||
if lerr != nil || len(addresses) == 0 {
|
||||
return "", "", strictErr
|
||||
}
|
||||
return host, addresses[0], nil
|
||||
}
|
||||
|
||||
// AssertMCPURLSafeSameOrigin validates a server-advertised URL (e.g., the POST
|
||||
// URL from an SSE endpoint event). Public URLs are accepted. Private URLs are
|
||||
// accepted only when RAGFLOW_MCP_ALLOW_PRIVATE_HOSTS is enabled AND the
|
||||
// advertised host matches originURL's host, so the server cannot bounce POSTs
|
||||
// to an unrelated internal target.
|
||||
func AssertMCPURLSafeSameOrigin(rawURL, originURL string) (hostname, resolvedIP string, err error) {
|
||||
hostname, resolvedIP, err = common.AssertURLSafe(rawURL)
|
||||
if err == nil {
|
||||
return hostname, resolvedIP, nil
|
||||
}
|
||||
if !mcpPrivateHostsAllowed() {
|
||||
return "", "", err
|
||||
}
|
||||
origin, perr := url.Parse(strings.TrimSpace(originURL))
|
||||
if perr != nil || origin.Hostname() == "" {
|
||||
return "", "", err
|
||||
}
|
||||
parsed, perr := url.Parse(strings.TrimSpace(rawURL))
|
||||
if perr != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if parsed.Hostname() != origin.Hostname() {
|
||||
return "", "", err
|
||||
}
|
||||
return AssertMCPURLSafe(rawURL)
|
||||
}
|
||||
|
||||
// mcpPrivateHostsAllowed reports whether RAGFLOW_MCP_ALLOW_PRIVATE_HOSTS is
|
||||
// enabled (true/1/yes, case-insensitive).
|
||||
func mcpPrivateHostsAllowed() bool {
|
||||
v := strings.ToLower(strings.TrimSpace(common.GetEnv(common.EnvMCPAllowPrivateHosts)))
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package utility
|
||||
|
||||
import (
|
||||
"ragflow/internal/common"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stubLookupHost(t *testing.T, ips []string) {
|
||||
t.Helper()
|
||||
original := common.LookupHost
|
||||
common.LookupHost = func(string) ([]string, error) { return ips, nil }
|
||||
t.Cleanup(func() { common.LookupHost = original })
|
||||
}
|
||||
|
||||
func TestAssertMCPURLSafePrivateHostAllowed(t *testing.T) {
|
||||
stubLookupHost(t, []string{"172.18.0.5"})
|
||||
t.Setenv(common.EnvMCPAllowPrivateHosts, "true")
|
||||
|
||||
host, ip, err := AssertMCPURLSafe("http://navigo-mcp:8765/mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("expected allowed private MCP host to pass, got %v", err)
|
||||
}
|
||||
if host != "navigo-mcp" || ip != "172.18.0.5" {
|
||||
t.Fatalf("expected pinning to resolved IP, got host=%q ip=%q", host, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMCPURLSafeRejectsPrivateHostWhenDisabled(t *testing.T) {
|
||||
stubLookupHost(t, []string{"172.18.0.5"})
|
||||
t.Setenv(common.EnvMCPAllowPrivateHosts, "")
|
||||
|
||||
if _, _, err := AssertMCPURLSafe("http://navigo-mcp:8765/mcp"); err == nil {
|
||||
t.Fatal("expected private host to be rejected when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMCPURLSafePublicHostUnaffected(t *testing.T) {
|
||||
stubLookupHost(t, []string{"93.184.216.34"})
|
||||
|
||||
host, ip, err := AssertMCPURLSafe("https://mcp.example.com/mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("expected public MCP host to pass, got %v", err)
|
||||
}
|
||||
if host != "mcp.example.com" || ip != "93.184.216.34" {
|
||||
t.Fatalf("unexpected host/ip: %q %q", host, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMCPURLSafeSameOriginAllowsPrivateAdvertisedURL(t *testing.T) {
|
||||
stubLookupHost(t, []string{"172.18.0.5"})
|
||||
t.Setenv(common.EnvMCPAllowPrivateHosts, "true")
|
||||
|
||||
host, ip, err := AssertMCPURLSafeSameOrigin("http://navigo-mcp:8765/mcp/messages", "http://navigo-mcp:8765/mcp/sse")
|
||||
if err != nil {
|
||||
t.Fatalf("expected same-origin private advertised URL to pass, got %v", err)
|
||||
}
|
||||
if host != "navigo-mcp" || ip != "172.18.0.5" {
|
||||
t.Fatalf("unexpected host/ip: %q %q", host, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMCPURLSafeSameOriginRejectsCrossOriginPrivateAdvertisedURL(t *testing.T) {
|
||||
stubLookupHost(t, []string{"172.18.0.6"})
|
||||
t.Setenv(common.EnvMCPAllowPrivateHosts, "true")
|
||||
|
||||
if _, _, err := AssertMCPURLSafeSameOrigin("http://other-internal:8765/mcp/messages", "http://navigo-mcp:8765/mcp/sse"); err == nil {
|
||||
t.Fatal("expected cross-origin private advertised URL to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user