aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrancis Lavoie <[email protected]>2023-03-27 16:22:59 -0400
committerGitHub <[email protected]>2023-03-27 20:22:59 +0000
commit05e9974570a08df14b1162a1e98315d4ee9ec2ee (patch)
treee11e345766dc53c25f901a7402ceb489b4416fe9
parent330be2d8c793147d3914f944eecb96c18f2eabff (diff)
downloadcaddy-05e9974570a08df14b1162a1e98315d4ee9ec2ee.tar.gz
caddy-05e9974570a08df14b1162a1e98315d4ee9ec2ee.zip
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
-rw-r--r--caddyconfig/httpcaddyfile/httptype.go1
-rw-r--r--caddyconfig/httpcaddyfile/serveroptions.go14
-rw-r--r--caddytest/integration/caddyfile_adapt/global_server_options_single.txt7
-rw-r--r--caddytest/integration/caddyfile_adapt/matcher_syntax.txt25
-rw-r--r--modules/caddyhttp/app.go5
-rw-r--r--modules/caddyhttp/ip_matchers.go344
-rw-r--r--modules/caddyhttp/marshalers.go1
-rw-r--r--modules/caddyhttp/matchers.go178
-rw-r--r--modules/caddyhttp/server.go74
9 files changed, 462 insertions, 187 deletions
diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go
index 50e98ac60..a066cebf6 100644
--- a/caddyconfig/httpcaddyfile/httptype.go
+++ b/caddyconfig/httpcaddyfile/httptype.go
@@ -1328,6 +1328,7 @@ func placeholderShorthands() []string {
"{tls_client_certificate_pem}", "{http.request.tls.client.certificate_pem}",
"{tls_client_certificate_der_base64}", "{http.request.tls.client.certificate_der_base64}",
"{upstream_hostport}", "{http.reverse_proxy.upstream.hostport}",
+ "{client_ip}", "{http.vars.client_ip}",
}
}
diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go
index eb57c58cb..f4274eabd 100644
--- a/caddyconfig/httpcaddyfile/serveroptions.go
+++ b/caddyconfig/httpcaddyfile/serveroptions.go
@@ -44,6 +44,7 @@ type serverOptions struct {
Protocols []string
StrictSNIHost *bool
TrustedProxiesRaw json.RawMessage
+ ClientIPHeaders []string
ShouldLogCredentials bool
Metrics *caddyhttp.Metrics
}
@@ -208,6 +209,18 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
)
serverOpts.TrustedProxiesRaw = jsonSource
+ case "client_ip_headers":
+ headers := d.RemainingArgs()
+ for _, header := range headers {
+ if sliceContains(serverOpts.ClientIPHeaders, header) {
+ return nil, d.Errf("client IP header %s specified more than once", header)
+ }
+ serverOpts.ClientIPHeaders = append(serverOpts.ClientIPHeaders, header)
+ }
+ if nesting := d.Nesting(); d.NextBlock(nesting) {
+ return nil, d.ArgErr()
+ }
+
case "metrics":
if d.NextArg() {
return nil, d.ArgErr()
@@ -317,6 +330,7 @@ func applyServerOptions(
server.Protocols = opts.Protocols
server.StrictSNIHost = opts.StrictSNIHost
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
+ server.ClientIPHeaders = opts.ClientIPHeaders
server.Metrics = opts.Metrics
if opts.ShouldLogCredentials {
if server.Logs == nil {
diff --git a/caddytest/integration/caddyfile_adapt/global_server_options_single.txt b/caddytest/integration/caddyfile_adapt/global_server_options_single.txt
index d963604eb..300b4aca7 100644
--- a/caddytest/integration/caddyfile_adapt/global_server_options_single.txt
+++ b/caddytest/integration/caddyfile_adapt/global_server_options_single.txt
@@ -15,6 +15,8 @@
protocols h1 h2 h2c h3
strict_sni_host
trusted_proxies static private_ranges
+ client_ip_headers Custom-Real-Client-IP X-Forwarded-For
+ client_ip_headers A-Third-One
}
}
@@ -67,6 +69,11 @@ foo.com {
],
"source": "static"
},
+ "client_ip_headers": [
+ "Custom-Real-Client-IP",
+ "X-Forwarded-For",
+ "A-Third-One"
+ ],
"logs": {
"should_log_credentials": true
},
diff --git a/caddytest/integration/caddyfile_adapt/matcher_syntax.txt b/caddytest/integration/caddyfile_adapt/matcher_syntax.txt
index fb3dfb660..ffab2c70d 100644
--- a/caddytest/integration/caddyfile_adapt/matcher_syntax.txt
+++ b/caddytest/integration/caddyfile_adapt/matcher_syntax.txt
@@ -43,6 +43,9 @@
@matcher11 remote_ip private_ranges
respond @matcher11 "remote_ip matcher with private ranges"
+
+ @matcher12 client_ip private_ranges
+ respond @matcher12 "client_ip matcher with private ranges"
}
----------
{
@@ -250,6 +253,28 @@
"handler": "static_response"
}
]
+ },
+ {
+ "match": [
+ {
+ "client_ip": {
+ "ranges": [
+ "192.168.0.0/16",
+ "172.16.0.0/12",
+ "10.0.0.0/8",
+ "127.0.0.1/8",
+ "fd00::/8",
+ "::1"
+ ]
+ }
+ }
+ ],
+ "handle": [
+ {
+ "body": "client_ip matcher with private ranges",
+ "handler": "static_response"
+ }
+ ]
}
]
}
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 670185a13..ceb62f441 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 000000000..8423c7dc5
--- /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 e6fc3a61e..9a955e3b6 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 3064300bb..f5f9a0f66 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 13ebbe61a..eb618067f 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"
)