Просмотр исходного кода

fix(fetcher): restrict private per-feed proxy access

Prevent per-feed proxy URLs from bypassing the private-network
restriction by default, avoiding a potential SSRF against internal
services.

Treat only application, rotating, and environment-configured proxies
as trusted hops. Private per-feed proxies now require the explicit
FETCHER_ALLOW_PRIVATE_FEED_PROXY=1 opt-in, preserving deployments that
depend on the behavior introduced by #4219.
Fred 1 день назад
Родитель
Сommit
f17c8fd32a

+ 9 - 0
internal/config/options.go

@@ -225,6 +225,11 @@ func NewConfigOptions() *configOptions {
 				rawValue:        "0",
 				valueType:       boolType,
 			},
+			"FETCHER_ALLOW_PRIVATE_FEED_PROXY": {
+				parsedBoolValue: false,
+				rawValue:        "0",
+				valueType:       boolType,
+			},
 			"FETCH_BILIBILI_WATCH_TIME": {
 				parsedBoolValue: false,
 				rawValue:        "0",
@@ -807,6 +812,10 @@ func (c *configOptions) FetcherAllowPrivateNetworks() bool {
 	return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
 }
 
+func (c *configOptions) FetcherAllowPrivateFeedProxy() bool {
+	return c.options["FETCHER_ALLOW_PRIVATE_FEED_PROXY"].parsedBoolValue
+}
+
 func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
 	if c == nil {
 		return false

+ 24 - 0
internal/config/options_parsing_test.go

@@ -1383,6 +1383,30 @@ func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
 	}
 }
 
+func TestFetcherAllowPrivateFeedProxyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FetcherAllowPrivateFeedProxy() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_FEED_PROXY to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_FEED_PROXY=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.FetcherAllowPrivateFeedProxy() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_FEED_PROXY to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_FEED_PROXY=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FetcherAllowPrivateFeedProxy() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_FEED_PROXY to be disabled")
+	}
+}
+
 func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
 

+ 17 - 1
internal/reader/fetcher/request_builder.go

@@ -36,6 +36,15 @@ var (
 	ErrPrivateNetworkHost = errors.New("fetcher: refusing to access private network host")
 )
 
+type proxySource int
+
+const (
+	proxySourceNone proxySource = iota
+	proxySourceFeed
+	proxySourceApplication
+	proxySourceRotator
+)
+
 type RequestBuilder struct {
 	headers            http.Header
 	clientProxyURL     *url.URL
@@ -154,6 +163,7 @@ func (r *RequestBuilder) WithoutCompression() *RequestBuilder {
 
 func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
 	var clientProxyURL *url.URL
+	proxySource := proxySourceNone
 
 	switch {
 	case r.feedProxyURL != "":
@@ -162,10 +172,13 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
 		if err != nil {
 			return nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)
 		}
+		proxySource = proxySourceFeed
 	case r.useClientProxy && r.clientProxyURL != nil:
 		clientProxyURL = r.clientProxyURL
+		proxySource = proxySourceApplication
 	case r.proxyRotator != nil && r.proxyRotator.HasProxies():
 		clientProxyURL = r.proxyRotator.GetNextProxy()
+		proxySource = proxySourceRotator
 	}
 
 	directDialer := &net.Dialer{
@@ -184,7 +197,10 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
 	envProxyFunc := httpproxy.FromEnvironment().ProxyFunc()
 
 	trustedProxies := &trustedProxyAddresses{}
-	trustedProxies.add(clientProxyURL)
+	allowPrivateFeedProxy := config.Opts != nil && config.Opts.FetcherAllowPrivateFeedProxy()
+	if proxySource != proxySourceFeed || allowPrivateFeedProxy {
+		trustedProxies.add(clientProxyURL)
+	}
 
 	// Perform the private-network check inside the dialer's Control callback,
 	// which fires after DNS resolution but before the TCP connection is made.

+ 71 - 7
internal/reader/fetcher/request_builder_test.go

@@ -467,12 +467,6 @@ func TestRequestBuilder_AllowPrivateConfiguredProxy(t *testing.T) {
 		name      string
 		configure func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder
 	}{
-		{
-			name: "feed proxy",
-			configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
-				return builder.WithCustomFeedProxyURL(proxyURL)
-			},
-		},
 		{
 			name: "application proxy",
 			configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
@@ -534,6 +528,71 @@ func TestRequestBuilder_AllowPrivateConfiguredProxy(t *testing.T) {
 	}
 }
 
+func TestRequestBuilder_RefusePrivateFeedProxyByDefault(t *testing.T) {
+	configureFetcherPrivateNetworkOptions(t, "0", "0")
+
+	proxyRequests := make(chan string, 1)
+	proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		select {
+		case proxyRequests <- r.URL.String():
+		default:
+		}
+
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer proxyServer.Close()
+
+	_, err := NewRequestBuilder().
+		WithCustomFeedProxyURL(proxyServer.URL).
+		ExecuteRequest("http://feed.invalid/rss.xml")
+	if err == nil {
+		t.Fatal("Expected private feed proxy request to be rejected")
+	}
+
+	if !strings.Contains(err.Error(), "refusing to access private network host") {
+		t.Fatalf("Unexpected error for private feed proxy request: %v", err)
+	}
+
+	select {
+	case gotURL := <-proxyRequests:
+		t.Fatalf("Expected private feed proxy to receive no request, got %q", gotURL)
+	default:
+	}
+}
+
+func TestRequestBuilder_AllowPrivateFeedProxyWhenEnabled(t *testing.T) {
+	configureFetcherPrivateNetworkOptions(t, "0", "1")
+
+	targetURL := "http://feed.invalid/rss.xml"
+	proxyRequests := make(chan string, 1)
+	proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		select {
+		case proxyRequests <- r.URL.String():
+		default:
+		}
+
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer proxyServer.Close()
+
+	resp, err := NewRequestBuilder().
+		WithCustomFeedProxyURL(proxyServer.URL).
+		ExecuteRequest(targetURL)
+	if err != nil {
+		t.Fatalf("Expected private feed proxy request to succeed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	select {
+	case gotURL := <-proxyRequests:
+		if gotURL != targetURL {
+			t.Fatalf("Expected proxy request URL to be %q, got %q", targetURL, gotURL)
+		}
+	default:
+		t.Fatal("Expected request to be sent through the private feed proxy")
+	}
+}
+
 func TestRequestBuilder_AllowPrivateEnvironmentProxy(t *testing.T) {
 	configureFetcherAllowPrivateNetworksOption(t, "0")
 
@@ -691,9 +750,14 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
 }
 
 func configureFetcherAllowPrivateNetworksOption(t *testing.T, value string) {
+	configureFetcherPrivateNetworkOptions(t, value, "0")
+}
+
+func configureFetcherPrivateNetworkOptions(t *testing.T, privateNetworks, privateFeedProxy string) {
 	t.Helper()
 
-	t.Setenv("FETCHER_ALLOW_PRIVATE_NETWORKS", value)
+	t.Setenv("FETCHER_ALLOW_PRIVATE_NETWORKS", privateNetworks)
+	t.Setenv("FETCHER_ALLOW_PRIVATE_FEED_PROXY", privateFeedProxy)
 
 	configParser := config.NewConfigParser()
 	parsedOptions, err := configParser.ParseEnvironmentVariables()

+ 5 - 0
miniflux.1

@@ -306,6 +306,11 @@ Set to 1 to allow outgoing fetcher requests to private or loopback networks\&.
 .br
 Disabled by default, private networks are refused\&.
 .TP
+.B FETCHER_ALLOW_PRIVATE_FEED_PROXY
+Set to 1 to allow feed proxy URLs to use private or loopback network addresses\&.
+.br
+Disabled by default\&. This option applies to all authenticated users and should only be enabled when the private proxy boundary is trusted\&.
+.TP
 .B FETCH_BILIBILI_WATCH_TIME
 Set the value to 1 to scrape video duration from Bilibili website and
 use it as a reading time\&.