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" )