From 05e9974570a08df14b1162a1e98315d4ee9ec2ee Mon Sep 17 00:00:00 2001
From: Francis Lavoie <lavofr@gmail.com>
Date: Mon, 27 Mar 2023 16:22:59 -0400
Subject: caddyhttp: Determine real client IP if trusted proxies configured
 (#5104)

* caddyhttp: Determine real client IP if trusted proxies configured

* Support customizing client IP header

* Implement client_ip matcher, deprecate remote_ip's forwarded option
---
 modules/caddyhttp/app.go         |   5 +
 modules/caddyhttp/ip_matchers.go | 344 +++++++++++++++++++++++++++++++++++++++
 modules/caddyhttp/marshalers.go  |   1 +
 modules/caddyhttp/matchers.go    | 178 --------------------
 modules/caddyhttp/server.go      |  74 ++++++++-
 5 files changed, 415 insertions(+), 187 deletions(-)
 create mode 100644 modules/caddyhttp/ip_matchers.go

(limited to 'modules')

diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 670185a..ceb62f4 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -232,6 +232,11 @@ func (app *App) Provision(ctx caddy.Context) error {
 			srv.trustedProxies = val.(IPRangeSource)
 		}
 
+		// set the default client IP header to read from
+		if srv.ClientIPHeaders == nil {
+			srv.ClientIPHeaders = []string{"X-Forwarded-For"}
+		}
+
 		// process each listener address
 		for i := range srv.Listen {
 			lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
diff --git a/modules/caddyhttp/ip_matchers.go b/modules/caddyhttp/ip_matchers.go
new file mode 100644
index 0000000..8423c7d
--- /dev/null
+++ b/modules/caddyhttp/ip_matchers.go
@@ -0,0 +1,344 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyhttp
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"net/netip"
+	"reflect"
+	"strings"
+
+	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+	"github.com/google/cel-go/cel"
+	"github.com/google/cel-go/common/types/ref"
+	"go.uber.org/zap"
+)
+
+// MatchRemoteIP matches requests by the remote IP address,
+// i.e. the IP address of the direct connection to Caddy.
+type MatchRemoteIP struct {
+	// The IPs or CIDR ranges to match.
+	Ranges []string `json:"ranges,omitempty"`
+
+	// If true, prefer the first IP in the request's X-Forwarded-For
+	// header, if present, rather than the immediate peer's IP, as
+	// the reference IP against which to match. Note that it is easy
+	// to spoof request headers. Default: false
+	// DEPRECATED: This is insecure, MatchClientIP should be used instead.
+	Forwarded bool `json:"forwarded,omitempty"`
+
+	// cidrs and zones vars should aligned always in the same
+	// length and indexes for matching later
+	cidrs  []*netip.Prefix
+	zones  []string
+	logger *zap.Logger
+}
+
+// MatchClientIP matches requests by the client IP address,
+// i.e. the resolved address, considering trusted proxies.
+type MatchClientIP struct {
+	// The IPs or CIDR ranges to match.
+	Ranges []string `json:"ranges,omitempty"`
+
+	// cidrs and zones vars should aligned always in the same
+	// length and indexes for matching later
+	cidrs  []*netip.Prefix
+	zones  []string
+	logger *zap.Logger
+}
+
+func init() {
+	caddy.RegisterModule(MatchRemoteIP{})
+	caddy.RegisterModule(MatchClientIP{})
+}
+
+// CaddyModule returns the Caddy module information.
+func (MatchRemoteIP) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "http.matchers.remote_ip",
+		New: func() caddy.Module { return new(MatchRemoteIP) },
+	}
+}
+
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
+func (m *MatchRemoteIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+	for d.Next() {
+		for d.NextArg() {
+			if d.Val() == "forwarded" {
+				if len(m.Ranges) > 0 {
+					return d.Err("if used, 'forwarded' must be first argument")
+				}
+				m.Forwarded = true
+				continue
+			}
+			if d.Val() == "private_ranges" {
+				m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
+				continue
+			}
+			m.Ranges = append(m.Ranges, d.Val())
+		}
+		if d.NextBlock(0) {
+			return d.Err("malformed remote_ip matcher: blocks are not supported")
+		}
+	}
+	return nil
+}
+
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+//
+//	expression remote_ip('forwarded', '192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
+func (MatchRemoteIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+	return CELMatcherImpl(
+		// name of the macro, this is the function name that users see when writing expressions.
+		"remote_ip",
+		// name of the function that the macro will be rewritten to call.
+		"remote_ip_match_request_list",
+		// internal data type of the MatchPath value.
+		[]*cel.Type{cel.ListType(cel.StringType)},
+		// function to convert a constant list of strings to a MatchPath instance.
+		func(data ref.Val) (RequestMatcher, error) {
+			refStringList := reflect.TypeOf([]string{})
+			strList, err := data.ConvertToNative(refStringList)
+			if err != nil {
+				return nil, err
+			}
+
+			m := MatchRemoteIP{}
+
+			for _, input := range strList.([]string) {
+				if input == "forwarded" {
+					if len(m.Ranges) > 0 {
+						return nil, errors.New("if used, 'forwarded' must be first argument")
+					}
+					m.Forwarded = true
+					continue
+				}
+				m.Ranges = append(m.Ranges, input)
+			}
+
+			err = m.Provision(ctx)
+			return m, err
+		},
+	)
+}
+
+// Provision parses m's IP ranges, either from IP or CIDR expressions.
+func (m *MatchRemoteIP) Provision(ctx caddy.Context) error {
+	m.logger = ctx.Logger()
+	cidrs, zones, err := provisionCidrsZonesFromRanges(m.Ranges)
+	if err != nil {
+		return err
+	}
+	m.cidrs = cidrs
+	m.zones = zones
+
+	if m.Forwarded {
+		m.logger.Warn("remote_ip's forwarded mode is deprecated; use the 'client_ip' matcher instead")
+	}
+
+	return nil
+}
+
+// Match returns true if r matches m.
+func (m MatchRemoteIP) Match(r *http.Request) bool {
+	address := r.RemoteAddr
+	if m.Forwarded {
+		if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" {
+			address = strings.TrimSpace(strings.Split(fwdFor, ",")[0])
+		}
+	}
+	clientIP, zoneID, err := parseIPZoneFromString(address)
+	if err != nil {
+		m.logger.Error("getting remote IP", zap.Error(err))
+		return false
+	}
+	matches, zoneFilter := matchIPByCidrZones(clientIP, zoneID, m.cidrs, m.zones)
+	if !matches && !zoneFilter {
+		m.logger.Debug("zone ID from remote IP did not match", zap.String("zone", zoneID))
+	}
+	return matches
+}
+
+// CaddyModule returns the Caddy module information.
+func (MatchClientIP) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "http.matchers.client_ip",
+		New: func() caddy.Module { return new(MatchClientIP) },
+	}
+}
+
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
+func (m *MatchClientIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+	for d.Next() {
+		for d.NextArg() {
+			if d.Val() == "private_ranges" {
+				m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
+				continue
+			}
+			m.Ranges = append(m.Ranges, d.Val())
+		}
+		if d.NextBlock(0) {
+			return d.Err("malformed client_ip matcher: blocks are not supported")
+		}
+	}
+	return nil
+}
+
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+//
+//	expression client_ip('192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
+func (MatchClientIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+	return CELMatcherImpl(
+		// name of the macro, this is the function name that users see when writing expressions.
+		"client_ip",
+		// name of the function that the macro will be rewritten to call.
+		"client_ip_match_request_list",
+		// internal data type of the MatchPath value.
+		[]*cel.Type{cel.ListType(cel.StringType)},
+		// function to convert a constant list of strings to a MatchPath instance.
+		func(data ref.Val) (RequestMatcher, error) {
+			refStringList := reflect.TypeOf([]string{})
+			strList, err := data.ConvertToNative(refStringList)
+			if err != nil {
+				return nil, err
+			}
+
+			m := MatchClientIP{
+				Ranges: strList.([]string),
+			}
+
+			err = m.Provision(ctx)
+			return m, err
+		},
+	)
+}
+
+// Provision parses m's IP ranges, either from IP or CIDR expressions.
+func (m *MatchClientIP) Provision(ctx caddy.Context) error {
+	m.logger = ctx.Logger()
+	cidrs, zones, err := provisionCidrsZonesFromRanges(m.Ranges)
+	if err != nil {
+		return err
+	}
+	m.cidrs = cidrs
+	m.zones = zones
+	return nil
+}
+
+// Match returns true if r matches m.
+func (m MatchClientIP) Match(r *http.Request) bool {
+	address := GetVar(r.Context(), ClientIPVarKey).(string)
+	clientIP, zoneID, err := parseIPZoneFromString(address)
+	if err != nil {
+		m.logger.Error("getting client IP", zap.Error(err))
+		return false
+	}
+	matches, zoneFilter := matchIPByCidrZones(clientIP, zoneID, m.cidrs, m.zones)
+	if !matches && !zoneFilter {
+		m.logger.Debug("zone ID from client IP did not match", zap.String("zone", zoneID))
+	}
+	return matches
+}
+
+func provisionCidrsZonesFromRanges(ranges []string) ([]*netip.Prefix, []string, error) {
+	cidrs := []*netip.Prefix{}
+	zones := []string{}
+	for _, str := range ranges {
+		// Exclude the zone_id from the IP
+		if strings.Contains(str, "%") {
+			split := strings.Split(str, "%")
+			str = split[0]
+			// write zone identifiers in m.zones for matching later
+			zones = append(zones, split[1])
+		} else {
+			zones = append(zones, "")
+		}
+		if strings.Contains(str, "/") {
+			ipNet, err := netip.ParsePrefix(str)
+			if err != nil {
+				return nil, nil, fmt.Errorf("parsing CIDR expression '%s': %v", str, err)
+			}
+			cidrs = append(cidrs, &ipNet)
+		} else {
+			ipAddr, err := netip.ParseAddr(str)
+			if err != nil {
+				return nil, nil, fmt.Errorf("invalid IP address: '%s': %v", str, err)
+			}
+			ipNew := netip.PrefixFrom(ipAddr, ipAddr.BitLen())
+			cidrs = append(cidrs, &ipNew)
+		}
+	}
+	return cidrs, zones, nil
+}
+
+func parseIPZoneFromString(address string) (netip.Addr, string, error) {
+	ipStr, _, err := net.SplitHostPort(address)
+	if err != nil {
+		ipStr = address // OK; probably didn't have a port
+	}
+
+	// Some IPv6-Adresses can contain zone identifiers at the end,
+	// which are separated with "%"
+	zoneID := ""
+	if strings.Contains(ipStr, "%") {
+		split := strings.Split(ipStr, "%")
+		ipStr = split[0]
+		zoneID = split[1]
+	}
+
+	ipAddr, err := netip.ParseAddr(ipStr)
+	if err != nil {
+		return netip.IPv4Unspecified(), "", err
+	}
+
+	return ipAddr, zoneID, nil
+}
+
+func matchIPByCidrZones(clientIP netip.Addr, zoneID string, cidrs []*netip.Prefix, zones []string) (bool, bool) {
+	zoneFilter := true
+	for i, ipRange := range cidrs {
+		if ipRange.Contains(clientIP) {
+			// Check if there are zone filters assigned and if they match.
+			if zones[i] == "" || zoneID == zones[i] {
+				return true, false
+			}
+			zoneFilter = false
+		}
+	}
+	return false, zoneFilter
+}
+
+// Interface guards
+var (
+	_ RequestMatcher        = (*MatchRemoteIP)(nil)
+	_ caddy.Provisioner     = (*MatchRemoteIP)(nil)
+	_ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
+	_ CELLibraryProducer    = (*MatchRemoteIP)(nil)
+
+	_ RequestMatcher        = (*MatchClientIP)(nil)
+	_ caddy.Provisioner     = (*MatchClientIP)(nil)
+	_ caddyfile.Unmarshaler = (*MatchClientIP)(nil)
+	_ CELLibraryProducer    = (*MatchClientIP)(nil)
+)
diff --git a/modules/caddyhttp/marshalers.go b/modules/caddyhttp/marshalers.go
index e6fc3a6..9a955e3 100644
--- a/modules/caddyhttp/marshalers.go
+++ b/modules/caddyhttp/marshalers.go
@@ -40,6 +40,7 @@ func (r LoggableHTTPRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error {
 
 	enc.AddString("remote_ip", ip)
 	enc.AddString("remote_port", port)
+	enc.AddString("client_ip", GetVar(r.Context(), ClientIPVarKey).(string))
 	enc.AddString("proto", r.Proto)
 	enc.AddString("method", r.Method)
 	enc.AddString("host", r.Host)
diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go
index 3064300..f5f9a0f 100644
--- a/modules/caddyhttp/matchers.go
+++ b/modules/caddyhttp/matchers.go
@@ -20,7 +20,6 @@ import (
 	"fmt"
 	"net"
 	"net/http"
-	"net/netip"
 	"net/textproto"
 	"net/url"
 	"path"
@@ -35,7 +34,6 @@ import (
 	"github.com/google/cel-go/cel"
 	"github.com/google/cel-go/common/types"
 	"github.com/google/cel-go/common/types/ref"
-	"go.uber.org/zap"
 )
 
 type (
@@ -176,24 +174,6 @@ type (
 	// "http/2", "http/3", or minimum versions: "http/2+", etc.
 	MatchProtocol string
 
-	// MatchRemoteIP matches requests by client IP (or CIDR range).
-	MatchRemoteIP struct {
-		// The IPs or CIDR ranges to match.
-		Ranges []string `json:"ranges,omitempty"`
-
-		// If true, prefer the first IP in the request's X-Forwarded-For
-		// header, if present, rather than the immediate peer's IP, as
-		// the reference IP against which to match. Note that it is easy
-		// to spoof request headers. Default: false
-		Forwarded bool `json:"forwarded,omitempty"`
-
-		// cidrs and zones vars should aligned always in the same
-		// length and indexes for matching later
-		cidrs  []*netip.Prefix
-		zones  []string
-		logger *zap.Logger
-	}
-
 	// MatchNot matches requests by negating the results of its matcher
 	// sets. A single "not" matcher takes one or more matcher sets. Each
 	// matcher set is OR'ed; in other words, if any matcher set returns
@@ -229,7 +209,6 @@ func init() {
 	caddy.RegisterModule(MatchHeader{})
 	caddy.RegisterModule(MatchHeaderRE{})
 	caddy.RegisterModule(new(MatchProtocol))
-	caddy.RegisterModule(MatchRemoteIP{})
 	caddy.RegisterModule(MatchNot{})
 }
 
@@ -1261,159 +1240,6 @@ func (m MatchNot) Match(r *http.Request) bool {
 	return true
 }
 
-// CaddyModule returns the Caddy module information.
-func (MatchRemoteIP) CaddyModule() caddy.ModuleInfo {
-	return caddy.ModuleInfo{
-		ID:  "http.matchers.remote_ip",
-		New: func() caddy.Module { return new(MatchRemoteIP) },
-	}
-}
-
-// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
-func (m *MatchRemoteIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
-	for d.Next() {
-		for d.NextArg() {
-			if d.Val() == "forwarded" {
-				if len(m.Ranges) > 0 {
-					return d.Err("if used, 'forwarded' must be first argument")
-				}
-				m.Forwarded = true
-				continue
-			}
-			if d.Val() == "private_ranges" {
-				m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
-				continue
-			}
-			m.Ranges = append(m.Ranges, d.Val())
-		}
-		if d.NextBlock(0) {
-			return d.Err("malformed remote_ip matcher: blocks are not supported")
-		}
-	}
-	return nil
-}
-
-// CELLibrary produces options that expose this matcher for use in CEL
-// expression matchers.
-//
-// Example:
-//
-//	expression remote_ip('forwarded', '192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
-func (MatchRemoteIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
-	return CELMatcherImpl(
-		// name of the macro, this is the function name that users see when writing expressions.
-		"remote_ip",
-		// name of the function that the macro will be rewritten to call.
-		"remote_ip_match_request_list",
-		// internal data type of the MatchPath value.
-		[]*cel.Type{cel.ListType(cel.StringType)},
-		// function to convert a constant list of strings to a MatchPath instance.
-		func(data ref.Val) (RequestMatcher, error) {
-			refStringList := reflect.TypeOf([]string{})
-			strList, err := data.ConvertToNative(refStringList)
-			if err != nil {
-				return nil, err
-			}
-
-			m := MatchRemoteIP{}
-
-			for _, input := range strList.([]string) {
-				if input == "forwarded" {
-					if len(m.Ranges) > 0 {
-						return nil, errors.New("if used, 'forwarded' must be first argument")
-					}
-					m.Forwarded = true
-					continue
-				}
-				m.Ranges = append(m.Ranges, input)
-			}
-
-			err = m.Provision(ctx)
-			return m, err
-		},
-	)
-}
-
-// Provision parses m's IP ranges, either from IP or CIDR expressions.
-func (m *MatchRemoteIP) Provision(ctx caddy.Context) error {
-	m.logger = ctx.Logger()
-	for _, str := range m.Ranges {
-		// Exclude the zone_id from the IP
-		if strings.Contains(str, "%") {
-			split := strings.Split(str, "%")
-			str = split[0]
-			// write zone identifiers in m.zones for matching later
-			m.zones = append(m.zones, split[1])
-		} else {
-			m.zones = append(m.zones, "")
-		}
-		if strings.Contains(str, "/") {
-			ipNet, err := netip.ParsePrefix(str)
-			if err != nil {
-				return fmt.Errorf("parsing CIDR expression '%s': %v", str, err)
-			}
-			m.cidrs = append(m.cidrs, &ipNet)
-		} else {
-			ipAddr, err := netip.ParseAddr(str)
-			if err != nil {
-				return fmt.Errorf("invalid IP address: '%s': %v", str, err)
-			}
-			ipNew := netip.PrefixFrom(ipAddr, ipAddr.BitLen())
-			m.cidrs = append(m.cidrs, &ipNew)
-		}
-	}
-	return nil
-}
-
-func (m MatchRemoteIP) getClientIP(r *http.Request) (netip.Addr, string, error) {
-	remote := r.RemoteAddr
-	zoneID := ""
-	if m.Forwarded {
-		if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" {
-			remote = strings.TrimSpace(strings.Split(fwdFor, ",")[0])
-		}
-	}
-	ipStr, _, err := net.SplitHostPort(remote)
-	if err != nil {
-		ipStr = remote // OK; probably didn't have a port
-	}
-	// Some IPv6-Adresses can contain zone identifiers at the end,
-	// which are separated with "%"
-	if strings.Contains(ipStr, "%") {
-		split := strings.Split(ipStr, "%")
-		ipStr = split[0]
-		zoneID = split[1]
-	}
-	ipAddr, err := netip.ParseAddr(ipStr)
-	if err != nil {
-		return netip.IPv4Unspecified(), "", err
-	}
-	return ipAddr, zoneID, nil
-}
-
-// Match returns true if r matches m.
-func (m MatchRemoteIP) Match(r *http.Request) bool {
-	clientIP, zoneID, err := m.getClientIP(r)
-	if err != nil {
-		m.logger.Error("getting client IP", zap.Error(err))
-		return false
-	}
-	zoneFilter := true
-	for i, ipRange := range m.cidrs {
-		if ipRange.Contains(clientIP) {
-			// Check if there are zone filters assigned and if they match.
-			if m.zones[i] == "" || zoneID == m.zones[i] {
-				return true
-			}
-			zoneFilter = false
-		}
-	}
-	if !zoneFilter {
-		m.logger.Debug("zone ID from remote did not match", zap.String("zone", zoneID))
-	}
-	return false
-}
-
 // MatchRegexp is an embedable type for matching
 // using regular expressions. It adds placeholders
 // to the request's replacer.
@@ -1588,8 +1414,6 @@ var (
 	_ RequestMatcher    = (*MatchHeaderRE)(nil)
 	_ caddy.Provisioner = (*MatchHeaderRE)(nil)
 	_ RequestMatcher    = (*MatchProtocol)(nil)
-	_ RequestMatcher    = (*MatchRemoteIP)(nil)
-	_ caddy.Provisioner = (*MatchRemoteIP)(nil)
 	_ RequestMatcher    = (*MatchNot)(nil)
 	_ caddy.Provisioner = (*MatchNot)(nil)
 	_ caddy.Provisioner = (*MatchRegexp)(nil)
@@ -1602,7 +1426,6 @@ var (
 	_ caddyfile.Unmarshaler = (*MatchHeader)(nil)
 	_ caddyfile.Unmarshaler = (*MatchHeaderRE)(nil)
 	_ caddyfile.Unmarshaler = (*MatchProtocol)(nil)
-	_ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
 	_ caddyfile.Unmarshaler = (*VarsMatcher)(nil)
 	_ caddyfile.Unmarshaler = (*MatchVarsRE)(nil)
 
@@ -1614,7 +1437,6 @@ var (
 	_ CELLibraryProducer = (*MatchHeader)(nil)
 	_ CELLibraryProducer = (*MatchHeaderRE)(nil)
 	_ CELLibraryProducer = (*MatchProtocol)(nil)
-	_ CELLibraryProducer = (*MatchRemoteIP)(nil)
 	// _ CELLibraryProducer = (*VarsMatcher)(nil)
 	// _ CELLibraryProducer = (*MatchVarsRE)(nil)
 
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index 13ebbe6..eb61806 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -130,6 +130,17 @@ type Server struct {
 	// to trust sensitive incoming `X-Forwarded-*` headers.
 	TrustedProxiesRaw json.RawMessage `json:"trusted_proxies,omitempty" caddy:"namespace=http.ip_sources inline_key=source"`
 
+	// The headers from which the client IP address could be
+	// read from. These will be considered in order, with the
+	// first good value being used as the client IP.
+	// By default, only `X-Forwarded-For` is considered.
+	//
+	// This depends on `trusted_proxies` being configured and
+	// the request being validated as coming from a trusted
+	// proxy, otherwise the client IP will be set to the direct
+	// remote IP address.
+	ClientIPHeaders []string `json:"client_ip_headers,omitempty"`
+
 	// Enables access logging and configures how access logs are handled
 	// in this server. To minimally enable access logs, simply set this
 	// to a non-null, empty struct.
@@ -690,10 +701,15 @@ func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter
 	// set up the context for the request
 	ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
 	ctx = context.WithValue(ctx, ServerCtxKey, s)
+
+	trusted, clientIP := determineTrustedProxy(r, s)
 	ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{
-		TrustedProxyVarKey: determineTrustedProxy(r, s),
+		TrustedProxyVarKey: trusted,
+		ClientIPVarKey:     clientIP,
 	})
+
 	ctx = context.WithValue(ctx, routeGroupCtxKey, make(map[string]struct{}))
+
 	var url2 url.URL // avoid letting this escape to the heap
 	ctx = context.WithValue(ctx, OriginalRequestCtxKey, originalRequest(r, &url2))
 	r = r.WithContext(ctx)
@@ -724,11 +740,12 @@ func originalRequest(req *http.Request, urlCopy *url.URL) http.Request {
 
 // determineTrustedProxy parses the remote IP address of
 // the request, and determines (if the server configured it)
-// if the client is a trusted proxy.
-func determineTrustedProxy(r *http.Request, s *Server) bool {
+// if the client is a trusted proxy. If trusted, also returns
+// the real client IP if possible.
+func determineTrustedProxy(r *http.Request, s *Server) (bool, string) {
 	// If there's no server, then we can't check anything
 	if s == nil {
-		return false
+		return false, ""
 	}
 
 	// Parse the remote IP, ignore the error as non-fatal,
@@ -738,7 +755,7 @@ func determineTrustedProxy(r *http.Request, s *Server) bool {
 	// remote address and used an invalid value.
 	clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
 	if err != nil {
-		return false
+		return false, ""
 	}
 
 	// Client IP may contain a zone if IPv6, so we need
@@ -746,20 +763,56 @@ func determineTrustedProxy(r *http.Request, s *Server) bool {
 	clientIP, _, _ = strings.Cut(clientIP, "%")
 	ipAddr, err := netip.ParseAddr(clientIP)
 	if err != nil {
-		return false
+		return false, ""
 	}
 
 	// Check if the client is a trusted proxy
 	if s.trustedProxies == nil {
-		return false
+		return false, ipAddr.String()
 	}
 	for _, ipRange := range s.trustedProxies.GetIPRanges(r) {
 		if ipRange.Contains(ipAddr) {
-			return true
+			// We trust the proxy, so let's try to
+			// determine the real client IP
+			return true, trustedRealClientIP(r, s.ClientIPHeaders, ipAddr.String())
 		}
 	}
 
-	return false
+	return false, ipAddr.String()
+}
+
+// trustedRealClientIP finds the client IP from the request assuming it is
+// from a trusted client. If there is no client IP headers, then the
+// direct remote address is returned. If there are client IP headers,
+// then the first value from those headers is used.
+func trustedRealClientIP(r *http.Request, headers []string, clientIP string) string {
+	// Read all the values of the configured client IP headers, in order
+	var values []string
+	for _, field := range headers {
+		values = append(values, r.Header.Values(field)...)
+	}
+
+	// If we don't have any values, then give up
+	if len(values) == 0 {
+		return clientIP
+	}
+
+	// Since there can be many header values, we need to
+	// join them together before splitting to get the full list
+	allValues := strings.Split(strings.Join(values, ","), ",")
+
+	// Get first valid left-most IP address
+	for _, ip := range allValues {
+		ip, _, _ = strings.Cut(strings.TrimSpace(ip), "%")
+		ipAddr, err := netip.ParseAddr(ip)
+		if err != nil {
+			continue
+		}
+		return ipAddr.String()
+	}
+
+	// We didn't find a valid IP
+	return clientIP
 }
 
 // cloneURL makes a copy of r.URL and returns a
@@ -787,4 +840,7 @@ const (
 
 	// For tracking whether the client is a trusted proxy
 	TrustedProxyVarKey string = "trusted_proxy"
+
+	// For tracking the real client IP (affected by trusted_proxy)
+	ClientIPVarKey string = "client_ip"
 )
-- 
cgit v1.2.3