From 12bcbe2c4924ecbf6730fc340a7a4250bddcc9be Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Mon, 6 Feb 2023 14:44:11 -0500 Subject: caddyhttp: Pluggable trusted proxy IP range sources (#5328) * caddyhttp: Pluggable trusted proxy IP range sources * Add request to the IPRangeSource interface --- caddyconfig/httpcaddyfile/serveroptions.go | 28 ++-- .../global_server_options_single.txt | 21 +-- .../caddyfile_adapt/reverse_proxy_options.txt | 4 +- modules/caddyhttp/app.go | 23 +--- modules/caddyhttp/ip_range.go | 142 +++++++++++++++++++++ modules/caddyhttp/server.go | 25 +--- 6 files changed, 188 insertions(+), 55 deletions(-) create mode 100644 modules/caddyhttp/ip_range.go diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go index dca4ede..eb57c58 100644 --- a/caddyconfig/httpcaddyfile/serveroptions.go +++ b/caddyconfig/httpcaddyfile/serveroptions.go @@ -43,7 +43,7 @@ type serverOptions struct { MaxHeaderBytes int Protocols []string StrictSNIHost *bool - TrustedProxies []string + TrustedProxiesRaw json.RawMessage ShouldLogCredentials bool Metrics *caddyhttp.Metrics } @@ -188,13 +188,25 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { serverOpts.StrictSNIHost = &boolVal case "trusted_proxies": - for d.NextArg() { - if d.Val() == "private_ranges" { - serverOpts.TrustedProxies = append(serverOpts.TrustedProxies, caddyhttp.PrivateRangesCIDR()...) - continue - } - serverOpts.TrustedProxies = append(serverOpts.TrustedProxies, d.Val()) + if !d.NextArg() { + return nil, d.Err("trusted_proxies expects an IP range source module name as its first argument") + } + modID := "http.ip_sources." + d.Val() + unm, err := caddyfile.UnmarshalModule(d, modID) + if err != nil { + return nil, err + } + source, ok := unm.(caddyhttp.IPRangeSource) + if !ok { + return nil, fmt.Errorf("module %s (%T) is not an IP range source", modID, unm) } + jsonSource := caddyconfig.JSONModuleObject( + source, + "source", + source.(caddy.Module).CaddyModule().ID.Name(), + nil, + ) + serverOpts.TrustedProxiesRaw = jsonSource case "metrics": if d.NextArg() { @@ -304,7 +316,7 @@ func applyServerOptions( server.MaxHeaderBytes = opts.MaxHeaderBytes server.Protocols = opts.Protocols server.StrictSNIHost = opts.StrictSNIHost - server.TrustedProxies = opts.TrustedProxies + server.TrustedProxiesRaw = opts.TrustedProxiesRaw 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 f767ea7..d963604 100644 --- a/caddytest/integration/caddyfile_adapt/global_server_options_single.txt +++ b/caddytest/integration/caddyfile_adapt/global_server_options_single.txt @@ -14,7 +14,7 @@ log_credentials protocols h1 h2 h2c h3 strict_sni_host - trusted_proxies private_ranges + trusted_proxies static private_ranges } } @@ -56,14 +56,17 @@ foo.com { } ], "strict_sni_host": true, - "trusted_proxies": [ - "192.168.0.0/16", - "172.16.0.0/12", - "10.0.0.0/8", - "127.0.0.1/8", - "fd00::/8", - "::1" - ], + "trusted_proxies": { + "ranges": [ + "192.168.0.0/16", + "172.16.0.0/12", + "10.0.0.0/8", + "127.0.0.1/8", + "fd00::/8", + "::1" + ], + "source": "static" + }, "logs": { "should_log_credentials": true }, diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_options.txt b/caddytest/integration/caddyfile_adapt/reverse_proxy_options.txt index e05f1b9..b22333a 100644 --- a/caddytest/integration/caddyfile_adapt/reverse_proxy_options.txt +++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_options.txt @@ -1,4 +1,3 @@ - https://example.com { reverse_proxy /path https://localhost:54321 { header_up Host {upstream_hostport} @@ -24,13 +23,12 @@ https://example.com { max_conns_per_host 5 keepalive_idle_conns_per_host 2 keepalive_interval 30s - + tls_renegotiation freely tls_except_ports 8181 8182 } } } - ---------- { "apps": { diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index da25d37..0ec80ce 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -20,9 +20,7 @@ import ( "fmt" "net" "net/http" - "net/netip" "strconv" - "strings" "sync" "time" @@ -224,22 +222,13 @@ func (app *App) Provision(ctx caddy.Context) error { srv.StrictSNIHost = &trueBool } - // parse trusted proxy CIDRs ahead of time - for _, str := range srv.TrustedProxies { - if strings.Contains(str, "/") { - ipNet, err := netip.ParsePrefix(str) - if err != nil { - return fmt.Errorf("parsing CIDR expression: '%s': %v", str, err) - } - srv.trustedProxies = append(srv.trustedProxies, 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()) - srv.trustedProxies = append(srv.trustedProxies, ipNew) + // set up the trusted proxies source + for srv.TrustedProxiesRaw != nil { + val, err := ctx.LoadModule(srv, "TrustedProxiesRaw") + if err != nil { + return fmt.Errorf("loading trusted proxies modules: %v", err) } + srv.trustedProxies = val.(IPRangeSource) } // process each listener address diff --git a/modules/caddyhttp/ip_range.go b/modules/caddyhttp/ip_range.go new file mode 100644 index 0000000..b1db254 --- /dev/null +++ b/modules/caddyhttp/ip_range.go @@ -0,0 +1,142 @@ +// 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 ( + "fmt" + "net/http" + "net/netip" + "strings" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func init() { + caddy.RegisterModule(StaticIPRange{}) +} + +// IPRangeSource gets a list of IP ranges. +// +// The request is passed as an argument to allow plugin implementations +// to have more flexibility. But, a plugin MUST NOT modify the request. +// The caller will have read the `r.RemoteAddr` before getting IP ranges. +// +// This should be a very fast function -- instant if possible. +// The list of IP ranges should be sourced as soon as possible if loaded +// from an external source (i.e. initially loaded during Provisioning), +// so that it's ready to be used when requests start getting handled. +// A read lock should probably be used to get the cached value if the +// ranges can change at runtime (e.g. periodically refreshed). +// Using a `caddy.UsagePool` may be a good idea to avoid having refetch +// the values when a config reload occurs, which would waste time. +// +// If the list of IP ranges cannot be sourced, then provisioning SHOULD +// fail. Getting the IP ranges at runtime MUST NOT fail, because it would +// cancel incoming requests. If refreshing the list fails, then the +// previous list of IP ranges should continue to be returned so that the +// server can continue to operate normally. +type IPRangeSource interface { + GetIPRanges(*http.Request) []netip.Prefix +} + +// StaticIPRange provides a static range of IP address prefixes (CIDRs). +type StaticIPRange struct { + // A static list of IP ranges (supports CIDR notation). + Ranges []string `json:"ranges,omitempty"` + + // Holds the parsed CIDR ranges from Ranges. + ranges []netip.Prefix +} + +// CaddyModule returns the Caddy module information. +func (StaticIPRange) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.ip_sources.static", + New: func() caddy.Module { return new(StaticIPRange) }, + } +} + +func (s *StaticIPRange) Provision(ctx caddy.Context) error { + for _, str := range s.Ranges { + prefix, err := CIDRExpressionToPrefix(str) + if err != nil { + return err + } + s.ranges = append(s.ranges, prefix) + } + + return nil +} + +func (s *StaticIPRange) GetIPRanges(_ *http.Request) []netip.Prefix { + return s.ranges +} + +// UnmarshalCaddyfile implements caddyfile.Unmarshaler. +func (m *StaticIPRange) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + if !d.Next() { + return nil + } + for d.NextArg() { + if d.Val() == "private_ranges" { + m.Ranges = append(m.Ranges, PrivateRangesCIDR()...) + continue + } + m.Ranges = append(m.Ranges, d.Val()) + } + return nil +} + +// CIDRExpressionToPrefix takes a string which could be either a +// CIDR expression or a single IP address, and returns a netip.Prefix. +func CIDRExpressionToPrefix(expr string) (netip.Prefix, error) { + // Having a slash means it should be a CIDR expression + if strings.Contains(expr, "/") { + prefix, err := netip.ParsePrefix(expr) + if err != nil { + return netip.Prefix{}, fmt.Errorf("parsing CIDR expression: '%s': %v", expr, err) + } + return prefix, nil + } + + // Otherwise it's likely a single IP address + parsed, err := netip.ParseAddr(expr) + if err != nil { + return netip.Prefix{}, fmt.Errorf("invalid IP address: '%s': %v", expr, err) + } + prefix := netip.PrefixFrom(parsed, parsed.BitLen()) + return prefix, nil +} + +// PrivateRangesCIDR returns a list of private CIDR range +// strings, which can be used as a configuration shortcut. +func PrivateRangesCIDR() []string { + return []string{ + "192.168.0.0/16", + "172.16.0.0/12", + "10.0.0.0/8", + "127.0.0.1/8", + "fd00::/8", + "::1", + } +} + +// Interface guards +var ( + _ caddy.Provisioner = (*StaticIPRange)(nil) + _ caddyfile.Unmarshaler = (*StaticIPRange)(nil) + _ IPRangeSource = (*StaticIPRange)(nil) +) diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 420cc4a..13ebbe6 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -118,7 +118,7 @@ type Server struct { // client authentication. StrictSNIHost *bool `json:"strict_sni_host,omitempty"` - // A list of IP ranges (supports CIDR notation) from which + // A module which provides a source of IP ranges, from which // requests should be trusted. By default, no proxies are // trusted. // @@ -128,7 +128,7 @@ type Server struct { // of needing to configure each of them. See the // `reverse_proxy` handler for example, which uses this // to trust sensitive incoming `X-Forwarded-*` headers. - TrustedProxies []string `json:"trusted_proxies,omitempty"` + TrustedProxiesRaw json.RawMessage `json:"trusted_proxies,omitempty" caddy:"namespace=http.ip_sources inline_key=source"` // Enables access logging and configures how access logs are handled // in this server. To minimally enable access logs, simply set this @@ -188,8 +188,7 @@ type Server struct { h3listeners []net.PacketConn // TODO: we have to hold these because quic-go won't close listeners it didn't create addresses []caddy.NetworkAddress - // Holds the parsed CIDR ranges from TrustedProxies - trustedProxies []netip.Prefix + trustedProxies IPRangeSource shutdownAt time.Time shutdownAtMu *sync.RWMutex @@ -751,7 +750,10 @@ func determineTrustedProxy(r *http.Request, s *Server) bool { } // Check if the client is a trusted proxy - for _, ipRange := range s.trustedProxies { + if s.trustedProxies == nil { + return false + } + for _, ipRange := range s.trustedProxies.GetIPRanges(r) { if ipRange.Contains(ipAddr) { return true } @@ -771,19 +773,6 @@ func cloneURL(from, to *url.URL) { } } -// PrivateRangesCIDR returns a list of private CIDR range -// strings, which can be used as a configuration shortcut. -func PrivateRangesCIDR() []string { - return []string{ - "192.168.0.0/16", - "172.16.0.0/12", - "10.0.0.0/8", - "127.0.0.1/8", - "fd00::/8", - "::1", - } -} - // Context keys for HTTP request context values. const ( // For referencing the server instance -- cgit v1.2.3