Parcourir la source

feat(mediaproxy): disallow the media proxy to fetch resources on private networks

This change avoid possible SSRF issues and it's configurable at the instance level
Frédéric Guillot il y a 3 mois
Parent
commit
6c83e8c477

+ 9 - 0
internal/config/options.go

@@ -355,6 +355,11 @@ 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",
@@ -830,6 +835,10 @@ 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
 }

+ 24 - 0
internal/config/options_parsing_test.go

@@ -1431,6 +1431,30 @@ 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()
 

+ 19 - 0
internal/ui/proxy.go

@@ -22,6 +22,7 @@ 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) {
@@ -81,6 +82,24 @@ 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),
 	)

+ 32 - 0
internal/urllib/url.go

@@ -6,7 +6,9 @@ package urllib // import "miniflux.app/v2/internal/urllib"
 import (
 	"errors"
 	"fmt"
+	"net"
 	"net/url"
+	"slices"
 	"strings"
 )
 
@@ -138,3 +140,33 @@ func JoinBaseURLAndPath(baseURL, path string) (string, error) {
 
 	return finalURL, nil
 }
+
+// ResolvesToPrivateIP resolves a hostname and returns true if
+// ANY resolved IP address is non-public.
+func ResolvesToPrivateIP(host string) (bool, error) {
+	ips, err := net.LookupIP(host)
+	if err != nil {
+		return false, err
+	}
+
+	if slices.ContainsFunc(ips, isNonPublicIP) {
+		return true, nil
+	}
+
+	return false, nil
+}
+
+// isNonPublicIP returns true if the given IP is private, loopback,
+// link-local, multicast, or unspecified.
+func isNonPublicIP(ip net.IP) bool {
+	if ip == nil {
+		return true
+	}
+
+	return ip.IsPrivate() ||
+		ip.IsLoopback() ||
+		ip.IsLinkLocalUnicast() ||
+		ip.IsLinkLocalMulticast() ||
+		ip.IsMulticast() ||
+		ip.IsUnspecified()
+}

+ 73 - 1
internal/urllib/url_test.go

@@ -3,7 +3,10 @@
 
 package urllib // import "miniflux.app/v2/internal/urllib"
 
-import "testing"
+import (
+	"net"
+	"testing"
+)
 
 func TestIsRelativePath(t *testing.T) {
 	scenarios := map[string]bool{
@@ -154,3 +157,72 @@ func TestJoinBaseURLAndPath(t *testing.T) {
 		})
 	}
 }
+
+func TestIsNonPublicIP(t *testing.T) {
+	testCases := []struct {
+		name     string
+		ipString string
+		want     bool
+	}{
+		{"nil", "", true},
+		{"private IPv4", "192.168.1.10", true},
+		{"loopback IPv4", "127.0.0.1", true},
+		{"link-local IPv4", "169.254.42.1", true},
+		{"multicast IPv4", "224.0.0.1", true},
+		{"unspecified IPv6", "::", true},
+		{"loopback IPv6", "::1", true},
+		{"multicast IPv6", "ff02::1", true},
+		{"public IPv4", "93.184.216.34", false},
+		{"public IPv6", "2001:4860:4860::8888", false},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			var ip net.IP
+			if tc.ipString != "" {
+				ip = net.ParseIP(tc.ipString)
+				if ip == nil {
+					t.Fatalf("unable to parse %q", tc.ipString)
+				}
+			}
+
+			if got := isNonPublicIP(ip); got != tc.want {
+				t.Fatalf("unexpected result for %s: got %v want %v", tc.name, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestResolvesToPrivateIP(t *testing.T) {
+	testCases := []struct {
+		name string
+		host string
+		want bool
+	}{
+		{"localhost", "localhost", true},
+		{"example.org", "example.org", false},
+		{"loopback IPv4 literal", "127.0.0.1", true},
+		{"loopback IPv6 literal", "::1", true},
+		{"private IPv4 literal", "192.168.1.1", true},
+		{"public IPv4 literal", "93.184.216.34", false},
+		{"public IPv6 literal", "2001:4860:4860::8888", false},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := ResolvesToPrivateIP(tc.host)
+			if err != nil {
+				t.Fatalf("unexpected error for %s: %v", tc.host, err)
+			}
+			if got != tc.want {
+				t.Fatalf("unexpected result for %s: got %v want %v", tc.name, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestResolvesToPrivateIPError(t *testing.T) {
+	if _, err := ResolvesToPrivateIP(""); err == nil {
+		t.Fatalf("expected an error for empty host")
+	}
+}

+ 6 - 1
miniflux.1

@@ -1,5 +1,5 @@
 .\" Manpage for miniflux.
-.TH "MINIFLUX" "1" "September 29, 2025" "\ \&" "\ \&"
+.TH "MINIFLUX" "1" "December 29, 2025" "\ \&" "\ \&"
 
 .SH NAME
 miniflux \- Minimalist and opinionated feed reader
@@ -393,6 +393,11 @@ Time limit in seconds before the media proxy HTTP client cancel 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