feat(mcp): allow private MCP server hosts via deployment allowlist (#20571)

This commit is contained in:
Andrej Kratky
2026-10-10 10:55:57 +08:00
committed by GitHub
parent dca84f6b25
commit 1cfc7a1de9
7 changed files with 188 additions and 7 deletions
+1
View File
@@ -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"
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
+1 -2
View File
@@ -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
}
+4 -3
View File
@@ -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) {
+89
View File
@@ -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"
}
+85
View File
@@ -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")
}
}