浏览代码

feat: add FETCHER_ALLOW_PRIVATE_NETWORKS option

Block outbound requests to private networks made by the fetcher
by default. The restriction now applies to all outgoing requests
performed by the fetcher.

Previous PR #3947 intentionally enforced this restriction only
for the media proxy and icon fetching, considering the
self-hosted nature of Miniflux.
Frédéric Guillot 3 月之前
父节点
当前提交
26824211aa

+ 1 - 0
Makefile

@@ -113,6 +113,7 @@ integration-test:
 	CREATE_ADMIN=1 \
 	RUN_MIGRATIONS=1 \
 	LOG_LEVEL=debug \
+	FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
 	go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
 
 	while ! nc -z localhost 8080; do sleep 1; done

+ 7 - 16
internal/config/options.go

@@ -219,6 +219,11 @@ func NewConfigOptions() *configOptions {
 				rawValue:        "0",
 				valueType:       boolType,
 			},
+			"FETCHER_ALLOW_PRIVATE_NETWORKS": {
+				parsedBoolValue: false,
+				rawValue:        "0",
+				valueType:       boolType,
+			},
 			"FETCH_BILIBILI_WATCH_TIME": {
 				parsedBoolValue: false,
 				rawValue:        "0",
@@ -293,11 +298,6 @@ func NewConfigOptions() *configOptions {
 				rawValue:        "0",
 				valueType:       boolType,
 			},
-			"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
-				parsedBoolValue: false,
-				rawValue:        "0",
-				valueType:       boolType,
-			},
 			"INVIDIOUS_INSTANCE": {
 				parsedStringValue: "yewtu.be",
 				rawValue:          "yewtu.be",
@@ -353,11 +353,6 @@ func NewConfigOptions() *configOptions {
 				rawValue:  "",
 				valueType: urlType,
 			},
-			"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
-				parsedBoolValue: false,
-				rawValue:        "0",
-				valueType:       boolType,
-			},
 			"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
 				parsedDuration: 120 * time.Second,
 				rawValue:       "120",
@@ -791,8 +786,8 @@ func (c *configOptions) HTTPS() bool {
 	return c.options["HTTPS"].parsedBoolValue
 }
 
-func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
-	return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
+func (c *configOptions) FetcherAllowPrivateNetworks() bool {
+	return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
 }
 
 func (c *configOptions) InvidiousInstance() string {
@@ -847,10 +842,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
 	return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
 }
 
-func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
-	return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
-}
-
 func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
 	return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
 }

+ 9 - 33
internal/config/options_parsing_test.go

@@ -1351,27 +1351,27 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
 	}
 }
 
-func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
+func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
 
-	if configParser.options.IconFetchAllowPrivateNetworks() {
-		t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
+	if configParser.options.FetcherAllowPrivateNetworks() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
 	}
 
-	if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
+	if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
 		t.Fatalf("Unexpected error: %v", err)
 	}
 
-	if !configParser.options.IconFetchAllowPrivateNetworks() {
-		t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
+	if !configParser.options.FetcherAllowPrivateNetworks() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
 	}
 
-	if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
+	if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
 		t.Fatalf("Unexpected error: %v", err)
 	}
 
-	if configParser.options.IconFetchAllowPrivateNetworks() {
-		t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
+	if configParser.options.FetcherAllowPrivateNetworks() {
+		t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
 	}
 }
 
@@ -1442,30 +1442,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
 	}
 }
 
-func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
-	configParser := NewConfigParser()
-
-	if configParser.options.MediaProxyAllowPrivateNetworks() {
-		t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
-	}
-
-	if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-
-	if !configParser.options.MediaProxyAllowPrivateNetworks() {
-		t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
-	}
-
-	if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-
-	if configParser.options.MediaProxyAllowPrivateNetworks() {
-		t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
-	}
-}
-
 func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
 

+ 21 - 0
internal/reader/fetcher/request_builder.go

@@ -6,6 +6,7 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
 import (
 	"crypto/tls"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"log/slog"
 	"net"
@@ -14,7 +15,9 @@ import (
 	"slices"
 	"time"
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/proxyrotator"
+	"miniflux.app/v2/internal/urllib"
 )
 
 const (
@@ -22,6 +25,11 @@ const (
 	defaultAcceptHeader      = "application/xml, application/atom+xml, application/rss+xml, application/rdf+xml, application/feed+json, text/html, */*;q=0.9"
 )
 
+var (
+	ErrHostnameResolution = errors.New("fetcher: unable to resolve request hostname")
+	ErrPrivateNetworkHost = errors.New("fetcher: refusing to access private network host")
+)
+
 type RequestBuilder struct {
 	headers            http.Header
 	clientProxyURL     *url.URL
@@ -201,6 +209,19 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
 		return nil, err
 	}
 
+	allowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()
+	if !allowPrivateNetworks {
+		hostname := req.URL.Hostname()
+		isPrivate, err := urllib.ResolvesToPrivateIP(hostname)
+		if err != nil {
+			return nil, fmt.Errorf("%w %q: %w", ErrHostnameResolution, hostname, err)
+		}
+
+		if isPrivate {
+			return nil, fmt.Errorf("%w %q", ErrPrivateNetworkHost, hostname)
+		}
+	}
+
 	req.Header = r.headers
 	if r.disableCompression {
 		req.Header.Set("Accept-Encoding", "identity")

+ 56 - 0
internal/reader/fetcher/request_builder_test.go

@@ -7,8 +7,11 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 	"time"
+
+	"miniflux.app/v2/internal/config"
 )
 
 func TestNewRequestBuilder(t *testing.T) {
@@ -398,6 +401,41 @@ func TestRequestBuilder_InvalidURL(t *testing.T) {
 	}
 }
 
+func TestRequestBuilder_RefusePrivateNetworkByDefault(t *testing.T) {
+	configureFetcherAllowPrivateNetworksOption(t, "0")
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	builder := NewRequestBuilder()
+	_, err := builder.ExecuteRequest(server.URL)
+	if err == nil {
+		t.Fatal("Expected private network request to be rejected")
+	}
+
+	if !strings.Contains(err.Error(), "refusing to access private network host") {
+		t.Fatalf("Unexpected error for private network request: %v", err)
+	}
+}
+
+func TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {
+	configureFetcherAllowPrivateNetworksOption(t, "1")
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	builder := NewRequestBuilder()
+	resp, err := builder.ExecuteRequest(server.URL)
+	if err != nil {
+		t.Fatalf("Expected private network request to succeed when enabled: %v", err)
+	}
+	defer resp.Body.Close()
+}
+
 func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
 	// Create a slow server
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -420,3 +458,21 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
 		t.Errorf("Expected timeout around 1s, took %v", duration)
 	}
 }
+
+func configureFetcherAllowPrivateNetworksOption(t *testing.T, value string) {
+	t.Helper()
+
+	t.Setenv("FETCHER_ALLOW_PRIVATE_NETWORKS", value)
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 0 - 40
internal/reader/icon/finder.go

@@ -160,10 +160,6 @@ func (f *iconFinder) downloadIcon(iconURL string) (*model.Icon, error) {
 		slog.String("icon_url", iconURL),
 	)
 
-	if err := ensureRemoteIconURLAllowed(iconURL, config.Opts.IconFetchAllowPrivateNetworks()); err != nil {
-		return nil, err
-	}
-
 	responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(iconURL))
 	defer responseHandler.Close()
 
@@ -334,39 +330,3 @@ func parseImageDataURL(value string) (*model.Icon, error) {
 		MimeType: mediaType,
 	}, nil
 }
-
-func ensureRemoteIconURLAllowed(iconURL string, allowPrivateNetworks bool) error {
-	parsedURL, err := url.Parse(iconURL)
-	if err != nil {
-		return fmt.Errorf("icon: invalid icon URL %q: %w", iconURL, err)
-	}
-
-	if !parsedURL.IsAbs() {
-		return fmt.Errorf("icon: icon URL %q must be absolute", iconURL)
-	}
-
-	scheme := strings.ToLower(parsedURL.Scheme)
-	if scheme != "http" && scheme != "https" {
-		return fmt.Errorf("icon: unsupported icon URL scheme %q", parsedURL.Scheme)
-	}
-
-	hostname := parsedURL.Hostname()
-	if hostname == "" {
-		return fmt.Errorf("icon: icon URL %q has no hostname", iconURL)
-	}
-
-	if allowPrivateNetworks {
-		return nil
-	}
-
-	isPrivate, err := urllib.ResolvesToPrivateIP(hostname)
-	if err != nil {
-		return fmt.Errorf("icon: unable to resolve icon hostname %q: %w", hostname, err)
-	}
-
-	if isPrivate {
-		return fmt.Errorf("icon: refusing to download icon from private network host %q", hostname)
-	}
-
-	return nil
-}

+ 0 - 18
internal/reader/icon/finder_test.go

@@ -398,24 +398,6 @@ func TestResizeIconWebp(t *testing.T) {
 	}
 }
 
-func TestEnsureRemoteIconURLAllowedRejectsPrivateNetworks(t *testing.T) {
-	if err := ensureRemoteIconURLAllowed("http://192.168.0.1/favicon.ico", false); err == nil {
-		t.Fatal("Expected private network hosts to be rejected")
-	}
-}
-
-func TestEnsureRemoteIconURLAllowedAllowsPublicNetworks(t *testing.T) {
-	if err := ensureRemoteIconURLAllowed("https://1.1.1.1/favicon.ico", false); err != nil {
-		t.Fatalf("Expected public network hosts to be allowed: %v", err)
-	}
-}
-
-func TestEnsureRemoteIconURLAllowedAllowsPrivateWhenEnabled(t *testing.T) {
-	if err := ensureRemoteIconURLAllowed("http://10.0.0.5/icon.png", true); err != nil {
-		t.Fatalf("Expected private network hosts to be allowed when explicitly enabled: %v", err)
-	}
-}
-
 func TestResizeInvalidImage(t *testing.T) {
 	icon := model.Icon{
 		Content:  []byte("invalid data"),

+ 9 - 18
internal/ui/proxy.go

@@ -22,7 +22,6 @@ import (
 	"miniflux.app/v2/internal/http/response/html"
 	"miniflux.app/v2/internal/reader/fetcher"
 	"miniflux.app/v2/internal/reader/rewrite"
-	"miniflux.app/v2/internal/urllib"
 )
 
 func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
@@ -83,23 +82,6 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
 
 	mediaURL := string(decodedURL)
 
-	if !config.Opts.MediaProxyAllowPrivateNetworks() {
-		if isPrivate, err := urllib.ResolvesToPrivateIP(parsedMediaURL.Hostname()); err != nil {
-			slog.Warn("MediaProxy: Unable to resolve hostname",
-				slog.String("media_url", mediaURL),
-				slog.Any("error", err),
-			)
-			html.Forbidden(w, r)
-			return
-		} else if isPrivate {
-			slog.Warn("MediaProxy: Refusing to access private IP address",
-				slog.String("media_url", mediaURL),
-			)
-			html.Forbidden(w, r)
-			return
-		}
-	}
-
 	slog.Debug("MediaProxy: Fetching remote resource",
 		slog.String("media_url", mediaURL),
 	)
@@ -123,6 +105,15 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
 
 	resp, err := requestBuilder.ExecuteRequest(mediaURL)
 	if err != nil {
+		if errors.Is(err, fetcher.ErrPrivateNetworkHost) || errors.Is(err, fetcher.ErrHostnameResolution) {
+			slog.Warn("MediaProxy: Refused remote resource",
+				slog.String("media_url", mediaURL),
+				slog.Any("error", err),
+			)
+			html.Forbidden(w, r)
+			return
+		}
+
 		slog.Error("MediaProxy: Unable to initialize HTTP client",
 			slog.String("media_url", mediaURL),
 			slog.Any("error", err),

+ 6 - 11
miniflux.1

@@ -1,5 +1,5 @@
 .\" Manpage for miniflux.
-.TH "MINIFLUX" "1" "January 5, 2026" "\ \&" "\ \&"
+.TH "MINIFLUX" "1" "February 28, 2026" "\ \&" "\ \&"
 
 .SH NAME
 miniflux \- Minimalist and opinionated feed reader
@@ -274,6 +274,11 @@ Set the value to 1 to disable the internal scheduler service\&.
 .br
 Default is false (The internal scheduler service is enabled)\&.
 .TP
+.B FETCHER_ALLOW_PRIVATE_NETWORKS
+Set to 1 to allow outgoing fetcher requests to private or loopback networks\&.
+.br
+Disabled by default, private networks are refused\&.
+.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\&.
@@ -340,11 +345,6 @@ Forces cookies to use secure flag and send HSTS header\&.
 .br
 Default is disabled\&.
 .TP
-.B ICON_FETCH_ALLOW_PRIVATE_NETWORKS
-Set to 1 to allow downloading favicons that resolve to private or loopback networks\&.
-.br
-Disabled by default, private networks are refused\&.
-.TP
 .B INVIDIOUS_INSTANCE
 Set a custom invidious instance to use\&.
 .br
@@ -402,11 +402,6 @@ Time limit in seconds before the media proxy HTTP client cancels the request\&.
 .br
 Default is 120 seconds\&.
 .TP
-.B MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS
-Set to 1 to allow proxying media that resolves to private or loopback networks\&.
-.br
-Disabled by default, private networks are refused\&.
-.TP
 .B MEDIA_PROXY_RESOURCE_TYPES
 A comma-separated list of media types to proxify. Supported values are: image, audio, video\&.
 .br