浏览代码

feat: add `TRUSTED_REVERSE_PROXY_NETWORKS` config option

Add an IP-based allow list to prevent spoofing of HTTP headers that
should only be set by trusted reverse proxies.

Note that `TRUSTED_REVERSE_PROXY_NETWORKS` must be configured when
`AUTH_PROXY_HEADER` is used.

The following HTTP headers are taken into consideration only when the
client is an allowed reverse proxy: `X-Forwarded-For`,
`X-Forwarded-Proto` and `X-Real-Ip`.
Frédéric Guillot 3 月之前
父节点
当前提交
561389da69

+ 6 - 0
internal/cli/cli.go

@@ -109,6 +109,12 @@ func Parse() {
 		}
 	}
 
+	if config.Opts.AuthProxyHeader() != "" {
+		if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
+			printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
+		}
+	}
+
 	if flagConfigDump {
 		fmt.Print(config.Opts)
 		return

+ 9 - 0
internal/config/options.go

@@ -568,6 +568,11 @@ func NewConfigOptions() *configOptions {
 					return validateGreaterOrEqualThan(rawValue, 1)
 				},
 			},
+			"TRUSTED_REVERSE_PROXY_NETWORKS": {
+				parsedStringList: []string{},
+				rawValue:         "",
+				valueType:        stringListType,
+			},
 			"WATCHDOG": {
 				parsedBoolValue: true,
 				rawValue:        "1",
@@ -970,6 +975,10 @@ func (c *configOptions) SchedulerRoundRobinMinInterval() time.Duration {
 	return c.options["SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"].parsedDuration
 }
 
+func (c *configOptions) TrustedReverseProxyNetworks() []string {
+	return c.options["TRUSTED_REVERSE_PROXY_NETWORKS"].parsedStringList
+}
+
 func (c *configOptions) Watchdog() bool {
 	return c.options["WATCHDOG"].parsedBoolValue
 }

+ 30 - 1
internal/config/options_parsing_test.go

@@ -3,7 +3,10 @@
 
 package config // import "miniflux.app/v2/internal/config"
 
-import "testing"
+import (
+	"slices"
+	"testing"
+)
 
 func TestBaseURLOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
@@ -1632,6 +1635,32 @@ func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
 	}
 }
 
+func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	// Test default value
+	defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
+	if len(defaultNetworks) != 0 {
+		t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
+	}
+
+	// Test valid value
+	if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
+	if len(allowedNetworks) != 2 {
+		t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
+	}
+	if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
+		t.Errorf("Expected 10.0.0.0/8 in allowed networks")
+	}
+	if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
+		t.Errorf("Expected 192.168.1.0/24 in allowed networks")
+	}
+}
+
 func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
 

+ 39 - 12
internal/http/request/client_ip.go

@@ -9,19 +9,46 @@ import (
 	"strings"
 )
 
+// IsTrustedIP checks if the given remote IP belongs to one of the trusted networks.
+func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
+	if remoteIP == "@" || strings.HasPrefix(remoteIP, "/") {
+		return true
+	}
+
+	ip := net.ParseIP(remoteIP)
+	if ip == nil {
+		return false
+	}
+
+	for _, cidr := range trustedNetworks {
+		_, network, err := net.ParseCIDR(cidr)
+		if err != nil {
+			continue
+		}
+
+		if network.Contains(ip) {
+			return true
+		}
+	}
+
+	return false
+}
+
 // FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
-func FindClientIP(r *http.Request) string {
-	headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
-	for _, header := range headers {
-		value := r.Header.Get(header)
-
-		if value != "" {
-			addresses := strings.Split(value, ",")
-			address := strings.TrimSpace(addresses[0])
-			address = dropIPv6zone(address)
-
-			if net.ParseIP(address) != nil {
-				return address
+func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
+	if isTrustedProxyClient {
+		headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
+		for _, header := range headers {
+			value := r.Header.Get(header)
+
+			if value != "" {
+				addresses := strings.Split(value, ",")
+				address := strings.TrimSpace(addresses[0])
+				address = dropIPv6zone(address)
+
+				if net.ParseIP(address) != nil {
+					return address
+				}
 			}
 		}
 	}

+ 46 - 14
internal/http/request/client_ip_test.go

@@ -10,27 +10,27 @@ import (
 
 func TestFindClientIPWithoutHeaders(t *testing.T) {
 	r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
-	if ip := FindClientIP(r); ip != "192.168.0.1" {
+	if ip := FindClientIP(r, false); ip != "192.168.0.1" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
 	r = &http.Request{RemoteAddr: "192.168.0.1"}
-	if ip := FindClientIP(r); ip != "192.168.0.1" {
+	if ip := FindClientIP(r, false); ip != "192.168.0.1" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
 	r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
-	if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
+	if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
 	r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
-	if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
+	if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
 	r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
-	if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
+	if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
@@ -41,7 +41,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
 	headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
 	r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "203.0.113.195" {
+	if ip := FindClientIP(r, true); ip != "203.0.113.195" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
@@ -50,7 +50,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
 	headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
 	r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
+	if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
@@ -59,7 +59,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
 	headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
 	r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
+	if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
@@ -68,7 +68,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
 	headers.Set("X-Forwarded-For", "70.41.3.18")
 	r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "70.41.3.18" {
+	if ip := FindClientIP(r, true); ip != "70.41.3.18" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 
@@ -77,7 +77,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
 	headers.Set("X-Forwarded-For", "fake IP")
 	r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "192.168.0.1" {
+	if ip := FindClientIP(r, true); ip != "192.168.0.1" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
@@ -87,7 +87,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
 	headers.Set("X-Real-Ip", "192.168.122.1")
 	r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "192.168.122.1" {
+	if ip := FindClientIP(r, true); ip != "192.168.122.1" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
@@ -99,7 +99,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
 
 	r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
 
-	if ip := FindClientIP(r); ip != "203.0.113.195" {
+	if ip := FindClientIP(r, true); ip != "203.0.113.195" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
@@ -107,7 +107,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
 func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
 	r := &http.Request{RemoteAddr: "@"}
 
-	if ip := FindClientIP(r); ip != "@" {
+	if ip := FindClientIP(r, false); ip != "@" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
@@ -119,7 +119,39 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
 
 	r := &http.Request{RemoteAddr: "@", Header: headers}
 
-	if ip := FindClientIP(r); ip != "203.0.113.195" {
+	if ip := FindClientIP(r, true); ip != "203.0.113.195" {
 		t.Fatalf(`Unexpected result, got: %q`, ip)
 	}
 }
+
+func TestIsTrustedIP(t *testing.T) {
+	trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
+
+	scenarios := []struct {
+		ip       string
+		expected bool
+	}{
+		{"127.0.0.1", true},
+		{"10.0.0.1", true},
+		{"::1", true},
+		{"192.168.1.1", false},
+		{"invalid", false},
+		{"@", true},
+		{"/tmp/miniflux.sock", true},
+	}
+
+	for _, scenario := range scenarios {
+		result := IsTrustedIP(scenario.ip, trustedNetworks)
+		if result != scenario.expected {
+			t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
+		}
+	}
+
+	if IsTrustedIP("127.0.0.1", nil) {
+		t.Error("Expected false when no trusted networks are defined")
+	}
+
+	if IsTrustedIP("127.0.0.1", []string{}) {
+		t.Error("Expected false when trusted networks list is empty")
+	}
+}

+ 1 - 26
internal/http/server/httpd.go

@@ -316,32 +316,7 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
 	}
 
 	remoteIP := request.FindRemoteIP(r)
-	if remoteIP == "@" {
-		// This indicates a request sent via a Unix socket, always consider these trusted.
-		return true
-	}
-
-	for _, cidr := range config.Opts.MetricsAllowedNetworks() {
-		_, network, err := net.ParseCIDR(cidr)
-		if err != nil {
-			slog.Error("Metrics endpoint accessed with invalid CIDR",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("client_user_agent", r.UserAgent()),
-				slog.String("client_remote_addr", r.RemoteAddr),
-				slog.String("cidr", cidr),
-			)
-			return false
-		}
-
-		// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
-		// The recommendation is to use HTTP Basic authentication.
-		if network.Contains(net.ParseIP(remoteIP)) {
-			return true
-		}
-	}
-
-	return false
+	return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
 }
 
 func printErrorAndExit(format string, a ...any) {

+ 4 - 2
internal/http/server/middleware.go

@@ -15,11 +15,13 @@ import (
 
 func middleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		clientIP := request.FindClientIP(r)
+		remoteIP := request.FindRemoteIP(r)
+		isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
+		clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
 		ctx := r.Context()
 		ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
 
-		if r.Header.Get("X-Forwarded-Proto") == "https" {
+		if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
 			config.Opts.SetHTTPSValue(true)
 		}
 

+ 16 - 0
internal/ui/middleware.go

@@ -206,6 +206,19 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 			return
 		}
 
+		remoteIP := request.FindRemoteIP(r)
+		trustedNetworks := config.Opts.TrustedReverseProxyNetworks()
+		if !request.IsTrustedIP(remoteIP, trustedNetworks) {
+			slog.Warn("[AuthProxy] Rejecting authentication request from untrusted proxy",
+				slog.String("remote_ip", remoteIP),
+				slog.String("client_ip", request.ClientIP(r)),
+				slog.String("user_agent", r.UserAgent()),
+				slog.Any("trusted_networks", trustedNetworks),
+			)
+			next.ServeHTTP(w, r)
+			return
+		}
+
 		username := r.Header.Get(config.Opts.AuthProxyHeader())
 		if username == "" {
 			next.ServeHTTP(w, r)
@@ -215,6 +228,7 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 		clientIP := request.ClientIP(r)
 		slog.Debug("[AuthProxy] Received authenticated requested",
 			slog.String("client_ip", clientIP),
+			slog.String("remote_ip", remoteIP),
 			slog.String("user_agent", r.UserAgent()),
 			slog.String("username", username),
 		)
@@ -230,6 +244,7 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 				slog.Debug("[AuthProxy] User doesn't exist and user creation is not allowed",
 					slog.Bool("authentication_failed", true),
 					slog.String("client_ip", clientIP),
+					slog.String("remote_ip", remoteIP),
 					slog.String("user_agent", r.UserAgent()),
 					slog.String("username", username),
 				)
@@ -252,6 +267,7 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 		slog.Info("[AuthProxy] User authenticated successfully",
 			slog.Bool("authentication_successful", true),
 			slog.String("client_ip", clientIP),
+			slog.String("remote_ip", remoteIP),
 			slog.String("user_agent", r.UserAgent()),
 			slog.Int64("user_id", user.ID),
 			slog.String("username", user.Username),

+ 8 - 1
miniflux.1

@@ -1,5 +1,5 @@
 .\" Manpage for miniflux.
-.TH "MINIFLUX" "1" "December 29, 2025" "\ \&" "\ \&"
+.TH "MINIFLUX" "1" "January 3, 2026" "\ \&" "\ \&"
 
 .SH NAME
 miniflux \- Minimalist and opinionated feed reader
@@ -149,6 +149,8 @@ Default is empty\&.
 .B AUTH_PROXY_HEADER
 Proxy authentication HTTP header\&.
 .br
+The option \fBTRUSTED_REVERSE_PROXY_NETWORKS\fR must be configured to allow the proxy to authenticate users\&.
+.br
 Default is empty.
 .TP
 .B AUTH_PROXY_USER_CREATION
@@ -571,6 +573,11 @@ Minimum interval in minutes for the round robin scheduler\&.
 .br
 Default is 60 minutes\&.
 .TP
+.B TRUSTED_REVERSE_PROXY_NETWORKS
+List of networks (CIDR notation) allowed to use the proxy authentication header, \fBX-Forwarded-For\fR, \fBX-Forwarded-Proto\fR, and \fBX-Real-Ip\fR headers\&.
+.br
+Default is empty\&.
+.TP
 .B WATCHDOG
 Enable or disable Systemd watchdog\&.
 .br