mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-27 06:03:48 +03:00
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
This commit is contained in:
parent
330be2d8c7
commit
05e9974570
9 changed files with 462 additions and 187 deletions
|
@ -1328,6 +1328,7 @@ func placeholderShorthands() []string {
|
||||||
"{tls_client_certificate_pem}", "{http.request.tls.client.certificate_pem}",
|
"{tls_client_certificate_pem}", "{http.request.tls.client.certificate_pem}",
|
||||||
"{tls_client_certificate_der_base64}", "{http.request.tls.client.certificate_der_base64}",
|
"{tls_client_certificate_der_base64}", "{http.request.tls.client.certificate_der_base64}",
|
||||||
"{upstream_hostport}", "{http.reverse_proxy.upstream.hostport}",
|
"{upstream_hostport}", "{http.reverse_proxy.upstream.hostport}",
|
||||||
|
"{client_ip}", "{http.vars.client_ip}",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,7 @@ type serverOptions struct {
|
||||||
Protocols []string
|
Protocols []string
|
||||||
StrictSNIHost *bool
|
StrictSNIHost *bool
|
||||||
TrustedProxiesRaw json.RawMessage
|
TrustedProxiesRaw json.RawMessage
|
||||||
|
ClientIPHeaders []string
|
||||||
ShouldLogCredentials bool
|
ShouldLogCredentials bool
|
||||||
Metrics *caddyhttp.Metrics
|
Metrics *caddyhttp.Metrics
|
||||||
}
|
}
|
||||||
|
@ -208,6 +209,18 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||||
)
|
)
|
||||||
serverOpts.TrustedProxiesRaw = jsonSource
|
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":
|
case "metrics":
|
||||||
if d.NextArg() {
|
if d.NextArg() {
|
||||||
return nil, d.ArgErr()
|
return nil, d.ArgErr()
|
||||||
|
@ -317,6 +330,7 @@ func applyServerOptions(
|
||||||
server.Protocols = opts.Protocols
|
server.Protocols = opts.Protocols
|
||||||
server.StrictSNIHost = opts.StrictSNIHost
|
server.StrictSNIHost = opts.StrictSNIHost
|
||||||
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
|
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
|
||||||
|
server.ClientIPHeaders = opts.ClientIPHeaders
|
||||||
server.Metrics = opts.Metrics
|
server.Metrics = opts.Metrics
|
||||||
if opts.ShouldLogCredentials {
|
if opts.ShouldLogCredentials {
|
||||||
if server.Logs == nil {
|
if server.Logs == nil {
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
protocols h1 h2 h2c h3
|
protocols h1 h2 h2c h3
|
||||||
strict_sni_host
|
strict_sni_host
|
||||||
trusted_proxies static private_ranges
|
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"
|
"source": "static"
|
||||||
},
|
},
|
||||||
|
"client_ip_headers": [
|
||||||
|
"Custom-Real-Client-IP",
|
||||||
|
"X-Forwarded-For",
|
||||||
|
"A-Third-One"
|
||||||
|
],
|
||||||
"logs": {
|
"logs": {
|
||||||
"should_log_credentials": true
|
"should_log_credentials": true
|
||||||
},
|
},
|
||||||
|
|
|
@ -43,6 +43,9 @@
|
||||||
|
|
||||||
@matcher11 remote_ip private_ranges
|
@matcher11 remote_ip private_ranges
|
||||||
respond @matcher11 "remote_ip matcher with 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"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,6 +232,11 @@ func (app *App) Provision(ctx caddy.Context) error {
|
||||||
srv.trustedProxies = val.(IPRangeSource)
|
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
|
// process each listener address
|
||||||
for i := range srv.Listen {
|
for i := range srv.Listen {
|
||||||
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
|
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
|
||||||
|
|
344
modules/caddyhttp/ip_matchers.go
Normal file
344
modules/caddyhttp/ip_matchers.go
Normal file
|
@ -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)
|
||||||
|
)
|
|
@ -40,6 +40,7 @@ func (r LoggableHTTPRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error {
|
||||||
|
|
||||||
enc.AddString("remote_ip", ip)
|
enc.AddString("remote_ip", ip)
|
||||||
enc.AddString("remote_port", port)
|
enc.AddString("remote_port", port)
|
||||||
|
enc.AddString("client_ip", GetVar(r.Context(), ClientIPVarKey).(string))
|
||||||
enc.AddString("proto", r.Proto)
|
enc.AddString("proto", r.Proto)
|
||||||
enc.AddString("method", r.Method)
|
enc.AddString("method", r.Method)
|
||||||
enc.AddString("host", r.Host)
|
enc.AddString("host", r.Host)
|
||||||
|
|
|
@ -20,7 +20,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
@ -35,7 +34,6 @@ import (
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
"github.com/google/cel-go/common/types"
|
"github.com/google/cel-go/common/types"
|
||||||
"github.com/google/cel-go/common/types/ref"
|
"github.com/google/cel-go/common/types/ref"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -176,24 +174,6 @@ type (
|
||||||
// "http/2", "http/3", or minimum versions: "http/2+", etc.
|
// "http/2", "http/3", or minimum versions: "http/2+", etc.
|
||||||
MatchProtocol string
|
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
|
// MatchNot matches requests by negating the results of its matcher
|
||||||
// sets. A single "not" matcher takes one or more matcher sets. Each
|
// 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
|
// matcher set is OR'ed; in other words, if any matcher set returns
|
||||||
|
@ -229,7 +209,6 @@ func init() {
|
||||||
caddy.RegisterModule(MatchHeader{})
|
caddy.RegisterModule(MatchHeader{})
|
||||||
caddy.RegisterModule(MatchHeaderRE{})
|
caddy.RegisterModule(MatchHeaderRE{})
|
||||||
caddy.RegisterModule(new(MatchProtocol))
|
caddy.RegisterModule(new(MatchProtocol))
|
||||||
caddy.RegisterModule(MatchRemoteIP{})
|
|
||||||
caddy.RegisterModule(MatchNot{})
|
caddy.RegisterModule(MatchNot{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1261,159 +1240,6 @@ func (m MatchNot) Match(r *http.Request) bool {
|
||||||
return true
|
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
|
// MatchRegexp is an embedable type for matching
|
||||||
// using regular expressions. It adds placeholders
|
// using regular expressions. It adds placeholders
|
||||||
// to the request's replacer.
|
// to the request's replacer.
|
||||||
|
@ -1588,8 +1414,6 @@ var (
|
||||||
_ RequestMatcher = (*MatchHeaderRE)(nil)
|
_ RequestMatcher = (*MatchHeaderRE)(nil)
|
||||||
_ caddy.Provisioner = (*MatchHeaderRE)(nil)
|
_ caddy.Provisioner = (*MatchHeaderRE)(nil)
|
||||||
_ RequestMatcher = (*MatchProtocol)(nil)
|
_ RequestMatcher = (*MatchProtocol)(nil)
|
||||||
_ RequestMatcher = (*MatchRemoteIP)(nil)
|
|
||||||
_ caddy.Provisioner = (*MatchRemoteIP)(nil)
|
|
||||||
_ RequestMatcher = (*MatchNot)(nil)
|
_ RequestMatcher = (*MatchNot)(nil)
|
||||||
_ caddy.Provisioner = (*MatchNot)(nil)
|
_ caddy.Provisioner = (*MatchNot)(nil)
|
||||||
_ caddy.Provisioner = (*MatchRegexp)(nil)
|
_ caddy.Provisioner = (*MatchRegexp)(nil)
|
||||||
|
@ -1602,7 +1426,6 @@ var (
|
||||||
_ caddyfile.Unmarshaler = (*MatchHeader)(nil)
|
_ caddyfile.Unmarshaler = (*MatchHeader)(nil)
|
||||||
_ caddyfile.Unmarshaler = (*MatchHeaderRE)(nil)
|
_ caddyfile.Unmarshaler = (*MatchHeaderRE)(nil)
|
||||||
_ caddyfile.Unmarshaler = (*MatchProtocol)(nil)
|
_ caddyfile.Unmarshaler = (*MatchProtocol)(nil)
|
||||||
_ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
|
|
||||||
_ caddyfile.Unmarshaler = (*VarsMatcher)(nil)
|
_ caddyfile.Unmarshaler = (*VarsMatcher)(nil)
|
||||||
_ caddyfile.Unmarshaler = (*MatchVarsRE)(nil)
|
_ caddyfile.Unmarshaler = (*MatchVarsRE)(nil)
|
||||||
|
|
||||||
|
@ -1614,7 +1437,6 @@ var (
|
||||||
_ CELLibraryProducer = (*MatchHeader)(nil)
|
_ CELLibraryProducer = (*MatchHeader)(nil)
|
||||||
_ CELLibraryProducer = (*MatchHeaderRE)(nil)
|
_ CELLibraryProducer = (*MatchHeaderRE)(nil)
|
||||||
_ CELLibraryProducer = (*MatchProtocol)(nil)
|
_ CELLibraryProducer = (*MatchProtocol)(nil)
|
||||||
_ CELLibraryProducer = (*MatchRemoteIP)(nil)
|
|
||||||
// _ CELLibraryProducer = (*VarsMatcher)(nil)
|
// _ CELLibraryProducer = (*VarsMatcher)(nil)
|
||||||
// _ CELLibraryProducer = (*MatchVarsRE)(nil)
|
// _ CELLibraryProducer = (*MatchVarsRE)(nil)
|
||||||
|
|
||||||
|
|
|
@ -130,6 +130,17 @@ type Server struct {
|
||||||
// to trust sensitive incoming `X-Forwarded-*` headers.
|
// to trust sensitive incoming `X-Forwarded-*` headers.
|
||||||
TrustedProxiesRaw json.RawMessage `json:"trusted_proxies,omitempty" caddy:"namespace=http.ip_sources inline_key=source"`
|
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
|
// Enables access logging and configures how access logs are handled
|
||||||
// in this server. To minimally enable access logs, simply set this
|
// in this server. To minimally enable access logs, simply set this
|
||||||
// to a non-null, empty struct.
|
// 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
|
// set up the context for the request
|
||||||
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
|
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
|
||||||
ctx = context.WithValue(ctx, ServerCtxKey, s)
|
ctx = context.WithValue(ctx, ServerCtxKey, s)
|
||||||
|
|
||||||
|
trusted, clientIP := determineTrustedProxy(r, s)
|
||||||
ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{
|
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{}))
|
ctx = context.WithValue(ctx, routeGroupCtxKey, make(map[string]struct{}))
|
||||||
|
|
||||||
var url2 url.URL // avoid letting this escape to the heap
|
var url2 url.URL // avoid letting this escape to the heap
|
||||||
ctx = context.WithValue(ctx, OriginalRequestCtxKey, originalRequest(r, &url2))
|
ctx = context.WithValue(ctx, OriginalRequestCtxKey, originalRequest(r, &url2))
|
||||||
r = r.WithContext(ctx)
|
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
|
// determineTrustedProxy parses the remote IP address of
|
||||||
// the request, and determines (if the server configured it)
|
// the request, and determines (if the server configured it)
|
||||||
// if the client is a trusted proxy.
|
// if the client is a trusted proxy. If trusted, also returns
|
||||||
func determineTrustedProxy(r *http.Request, s *Server) bool {
|
// 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 there's no server, then we can't check anything
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the remote IP, ignore the error as non-fatal,
|
// 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.
|
// remote address and used an invalid value.
|
||||||
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client IP may contain a zone if IPv6, so we need
|
// 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, "%")
|
clientIP, _, _ = strings.Cut(clientIP, "%")
|
||||||
ipAddr, err := netip.ParseAddr(clientIP)
|
ipAddr, err := netip.ParseAddr(clientIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the client is a trusted proxy
|
// Check if the client is a trusted proxy
|
||||||
if s.trustedProxies == nil {
|
if s.trustedProxies == nil {
|
||||||
return false
|
return false, ipAddr.String()
|
||||||
}
|
}
|
||||||
for _, ipRange := range s.trustedProxies.GetIPRanges(r) {
|
for _, ipRange := range s.trustedProxies.GetIPRanges(r) {
|
||||||
if ipRange.Contains(ipAddr) {
|
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
|
// cloneURL makes a copy of r.URL and returns a
|
||||||
|
@ -787,4 +840,7 @@ const (
|
||||||
|
|
||||||
// For tracking whether the client is a trusted proxy
|
// For tracking whether the client is a trusted proxy
|
||||||
TrustedProxyVarKey string = "trusted_proxy"
|
TrustedProxyVarKey string = "trusted_proxy"
|
||||||
|
|
||||||
|
// For tracking the real client IP (affected by trusted_proxy)
|
||||||
|
ClientIPVarKey string = "client_ip"
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue