8000 [pull] main from miniflux:main by pull[bot] · Pull Request #203 · suhaoGe/miniflux · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Expand Down
31 changes: 30 additions & 1 deletion internal/config/options_parsing_test.go
8000
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

package config // import "miniflux.app/v2/internal/config"

import "testing"
import (
"slices"
"testing"
)

func TestBaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
Expand Down Expand Up @@ -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()

Expand Down
51 changes: 39 additions & 12 deletions internal/http/request/client_ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down
60 changes: 46 additions & 14 deletions internal/http/request/client_ip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}
}
Expand All @@ -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)
}
}
Expand All @@ -99,15 +99,15 @@ 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)
}
}

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)
}
}
Expand All @@ -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")
}
}
27 changes: 1 addition & 26 deletions internal/http/server/httpd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions internal/http/server/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions internal/ui/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
)
Expand All @@ -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),
)
Expand All @@ -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),
Expand Down
Loading
Loading
0