diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa10f10..b32fa07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ ## Changes since v5.1.1 +- [#552](https://github.com/oauth2-proxy/oauth2-proxy/pull/522) Implements --trusted-ip option to allow clients behind specified IPs or CIDR ranges to bypass authentication (@Izzette) - [GHSA-5m6c-jp6f-2vcv](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-5m6c-jp6f-2vcv) New OpenRedirect cases have been found (@JoelSpeed) - [#639](https://github.com/oauth2-proxy/oauth2-proxy/pull/639) Change how gitlab-group is parsed on options (@linuxgemini) - [#615](https://github.com/oauth2-proxy/oauth2-proxy/pull/615) Kubernetes example based on Kind cluster and Nginx ingress (@EvgeniGordeev) diff --git a/contrib/oauth2-proxy_autocomplete.sh b/contrib/oauth2-proxy_autocomplete.sh index f7d6ec68..d2c71c92 100644 --- a/contrib/oauth2-proxy_autocomplete.sh +++ b/contrib/oauth2-proxy_autocomplete.sh @@ -24,7 +24,7 @@ _oauth2_proxy() { COMPREPLY=( $(compgen -W 'X-Real-IP X-Forwarded-For X-ProxyUser-IP' -- ${cur}) ) return 0 ;; - --@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url)) + --@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url)) return 0 ;; esac diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index eaee3610..4642e6fa 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -129,6 +129,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example | `--validate-url` | string | Access token validation endpoint | | | `--version` | n/a | print version string | | | `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (eg `.example.com`) | | +| `--trusted-ip` | string \| list | list of IPs or CIDR ranges to allow to bypass authentication (may be given multiple times). When combined with `--reverse-proxy` and optionally `--real-client-ip-header` this will evaluate the trust of the IP stored in a HTTP header by a reverse proxy rather than the layer-3/4 remote address. WARNING: trusting IPs has inherent security flaws, especially when obtaining the IP address from an HTTP header (reverse-proxy mode). Use this option only if you understand the risks and how to manage them. | | Note: when using the `whitelist-domain` option, any domain prefixed with a `.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`. diff --git a/oauthproxy.go b/oauthproxy.go index d6c4bce1..49a34b2a 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -117,6 +117,7 @@ type OAuthProxy struct { compiledRegex []*regexp.Regexp templates *template.Template realClientIPParser ipapi.RealClientIPParser + trustedIPs *ip.NetSet Banner string Footer string } @@ -303,6 +304,15 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr logger.Printf("Cookie settings: name:%s secure(https):%v httponly:%v expiry:%s domains:%s path:%s samesite:%s refresh:%s", opts.Cookie.Name, opts.Cookie.Secure, opts.Cookie.HTTPOnly, opts.Cookie.Expire, strings.Join(opts.Cookie.Domains, ","), opts.Cookie.Path, opts.Cookie.SameSite, refresh) + trustedIPs := ip.NewNetSet() + for _, ipStr := range opts.TrustedIPs { + if ipNet := ip.ParseIPNet(ipStr); ipNet != nil { + trustedIPs.AddIPNet(*ipNet) + } else { + return nil, fmt.Errorf("could not parse IP network (%s)", ipStr) + } + } + return &OAuthProxy{ CookieName: opts.Cookie.Name, CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"), @@ -349,6 +359,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr PreferEmailToUser: opts.PreferEmailToUser, SkipProviderButton: opts.SkipProviderButton, templates: loadTemplates(opts.CustomTemplatesDir), + trustedIPs: trustedIPs, Banner: opts.Banner, Footer: opts.Footer, }, nil @@ -650,7 +661,7 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool { // IsWhitelistedRequest is used to check if auth should be skipped for this request func (p *OAuthProxy) IsWhitelistedRequest(req *http.Request) bool { isPreflightRequestAllowed := p.skipAuthPreflight && req.Method == "OPTIONS" - return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path) + return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path) || p.IsTrustedIP(req) } // IsWhitelistedPath is used to check if the request path is allowed without auth @@ -678,6 +689,26 @@ func prepareNoCache(w http.ResponseWriter) { } } +// IsTrustedIP is used to check if a request comes from a trusted client IP address. +func (p *OAuthProxy) IsTrustedIP(req *http.Request) bool { + if p.trustedIPs == nil { + return false + } + + remoteAddr, err := ip.GetClientIP(p.realClientIPParser, req) + if err != nil { + logger.Printf("Error obtaining real IP for trusted IP list: %v", err) + // Possibly spoofed X-Real-IP header + return false + } + + if remoteAddr == nil { + return false + } + + return p.trustedIPs.Has(remoteAddr) +} + func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if req.URL.Path != p.AuthOnlyPath && strings.HasPrefix(req.URL.Path, p.ProxyPrefix) { prepareNoCache(rw) diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 959cd538..0510a252 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -1892,3 +1892,166 @@ func baseTestOptions() *options.Options { opts.EmailDomains = []string{"*"} return opts } + +func TestTrustedIPs(t *testing.T) { + tests := []struct { + name string + trustedIPs []string + reverseProxy bool + realClientIPHeader string + req *http.Request + expectTrusted bool + }{ + // Check unconfigured behavior. + { + name: "Default", + trustedIPs: nil, + reverseProxy: false, + realClientIPHeader: "X-Real-IP", // Default value + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + return req + }(), + expectTrusted: false, + }, + // Check using req.RemoteAddr (Options.ReverseProxy == false). + { + name: "WithRemoteAddr", + trustedIPs: []string{"127.0.0.1"}, + reverseProxy: false, + realClientIPHeader: "X-Real-IP", // Default value + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:43670" + return req + }(), + expectTrusted: true, + }, + // Check ignores req.RemoteAddr match when behind a reverse proxy / missing header. + { + name: "IgnoresRemoteAddrInReverseProxyMode", + trustedIPs: []string{"127.0.0.1"}, + reverseProxy: true, + realClientIPHeader: "X-Real-IP", // Default value + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:44324" + return req + }(), + expectTrusted: false, + }, + // Check successful trusting of localhost in IPv4. + { + name: "TrustsLocalhostInReverseProxyMode", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Forwarded-For", "127.0.0.1") + return req + }(), + expectTrusted: true, + }, + // Check successful trusting of localhost in IPv6. + { + name: "TrustsIP6LocalostInReverseProxyMode", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Forwarded-For", "::1") + return req + }(), + expectTrusted: true, + }, + // Check does not trust random IPv4 address. + { + name: "DoesNotTrustRandomIP4Address", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Forwarded-For", "12.34.56.78") + return req + }(), + expectTrusted: false, + }, + // Check does not trust random IPv6 address. + { + name: "DoesNotTrustRandomIP6Address", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Forwarded-For", "::2") + return req + }(), + expectTrusted: false, + }, + // Check respects correct header. + { + name: "RespectsCorrectHeaderInReverseProxyMode", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Real-IP", "::1") + return req + }(), + expectTrusted: false, + }, + // Check doesn't trust if garbage is provided. + { + name: "DoesNotTrustGarbageInReverseProxyMode", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: true, + realClientIPHeader: "X-Forwarded-For", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("X-Forwarded-For", "adsfljk29242as!!") + return req + }(), + expectTrusted: false, + }, + // Check doesn't trust if garbage is provided (no reverse-proxy). + { + name: "DoesNotTrustGarbage", + trustedIPs: []string{"127.0.0.0/8", "::1"}, + reverseProxy: false, + realClientIPHeader: "X-Real-IP", + req: func() *http.Request { + req, _ := http.NewRequest("GET", "/", nil) + req.RemoteAddr = "adsfljk29242as!!" + return req + }(), + expectTrusted: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := baseTestOptions() + opts.Upstreams = []string{"static://200"} + opts.TrustedIPs = tt.trustedIPs + opts.ReverseProxy = tt.reverseProxy + opts.RealClientIPHeader = tt.realClientIPHeader + validation.Validate(opts) + + proxy, err := NewOAuthProxy(opts, func(string) bool { return true }) + assert.NoError(t, err) + rw := httptest.NewRecorder() + + proxy.ServeHTTP(rw, tt.req) + if tt.expectTrusted { + assert.Equal(t, 200, rw.Code) + } else { + assert.Equal(t, 403, rw.Code) + } + }) + } +} diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index 52d3cf02..4dd7f1d2 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -21,21 +21,22 @@ type SignatureData struct { // Options holds Configuration Options that can be set by Command Line Flag, // or Config File type Options struct { - ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy_prefix"` - PingPath string `flag:"ping-path" cfg:"ping_path"` - PingUserAgent string `flag:"ping-user-agent" cfg:"ping_user_agent"` - ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"` - HTTPAddress string `flag:"http-address" cfg:"http_address"` - HTTPSAddress string `flag:"https-address" cfg:"https_address"` - ReverseProxy bool `flag:"reverse-proxy" cfg:"reverse_proxy"` - RealClientIPHeader string `flag:"real-client-ip-header" cfg:"real_client_ip_header"` - ForceHTTPS bool `flag:"force-https" cfg:"force_https"` - RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"` - ClientID string `flag:"client-id" cfg:"client_id"` - ClientSecret string `flag:"client-secret" cfg:"client_secret"` - ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` - TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"` - TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"` + ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy_prefix"` + PingPath string `flag:"ping-path" cfg:"ping_path"` + PingUserAgent string `flag:"ping-user-agent" cfg:"ping_user_agent"` + ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"` + HTTPAddress string `flag:"http-address" cfg:"http_address"` + HTTPSAddress string `flag:"https-address" cfg:"https_address"` + ReverseProxy bool `flag:"reverse-proxy" cfg:"reverse_proxy"` + RealClientIPHeader string `flag:"real-client-ip-header" cfg:"real_client_ip_header"` + TrustedIPs []string `flag:"trusted-ip" cfg:"trusted_ips"` + ForceHTTPS bool `flag:"force-https" cfg:"force_https"` + RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"` + ClientID string `flag:"client-id" cfg:"client_id"` + ClientSecret string `flag:"client-secret" cfg:"client_secret"` + ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` + TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"` + TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"` AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` KeycloakGroup string `flag:"keycloak-group" cfg:"keycloak_group"` @@ -186,6 +187,7 @@ func NewFlagSet() *pflag.FlagSet { flagSet.String("https-address", ":443", ": to listen on for HTTPS clients") flagSet.Bool("reverse-proxy", false, "are we running behind a reverse proxy, controls whether headers like X-Real-Ip are accepted") flagSet.String("real-client-ip-header", "X-Real-IP", "Header used to determine the real IP of the client (one of: X-Forwarded-For, X-Real-IP, or X-ProxyUser-IP)") + flagSet.StringSlice("trusted-ip", []string{}, "list of IPs or CIDR ranges to allow to bypass authentication. WARNING: trusting by IP has inherent security flaws, read the configuration documentation for more information.") flagSet.Bool("force-https", false, "force HTTPS redirect for HTTP requests") flagSet.String("tls-cert-file", "", "path to certificate file") flagSet.String("tls-key-file", "", "path to private key file") diff --git a/pkg/ip/net_set.go b/pkg/ip/net_set.go new file mode 100644 index 00000000..2301a972 --- /dev/null +++ b/pkg/ip/net_set.go @@ -0,0 +1,112 @@ +package ip + +import ( + "fmt" + "net" +) + +// Fast lookup table for intersection of a single IP address within a collection of CIDR networks. +// +// Supports 4-byte (IPv4) and 16-byte (IPv6) networks. +// +// Provides O(1) best-case, O(log(n)) worst-case performance. +// In practice netmasks included will generally only be of standard lengths: +// - /8, /16, /24, and /32 for IPv4 +// - /64 and /128 for IPv6. +// As a result, typical lookup times will lean closer to best-case rather than worst-case even when most of the internet +// is included. +type NetSet struct { + ip4NetMaps []ipNetMap + ip6NetMaps []ipNetMap +} + +// Create a new NetSet with all of the provided networks. +func NewNetSet() *NetSet { + return &NetSet{ + ip4NetMaps: make([]ipNetMap, 0), + ip6NetMaps: make([]ipNetMap, 0), + } +} + +// Check if `ip` is in the set, true if within the set otherwise false. +func (w *NetSet) Has(ip net.IP) bool { + netMaps := w.getNetMaps(ip) + + // Check all ipNetMaps for intersection with `ip`. + for _, netMap := range *netMaps { + if netMap.has(ip) { + return true + } + } + return false +} + +// Add an CIDR network to the set. +func (w *NetSet) AddIPNet(ipNet net.IPNet) { + netMaps := w.getNetMaps(ipNet.IP) + + // Determine the size / number of ones in the CIDR network mask. + ones, _ := ipNet.Mask.Size() + + var netMap *ipNetMap + + // Search for the ipNetMap containing networks with the same number of ones. + for i := 0; len(*netMaps) > i; i++ { + if netMapOnes, _ := (*netMaps)[i].mask.Size(); netMapOnes == ones { + netMap = &(*netMaps)[i] + break + } + } + + // Create a new ipNetMap if none with this number of ones have been created yet. + if netMap == nil { + netMap = &ipNetMap{ + mask: ipNet.Mask, + ips: make(map[string]bool), + } + *netMaps = append(*netMaps, *netMap) + // Recurse once now that there exists an netMap. + w.AddIPNet(ipNet) + return + } + + // Add the IP to the ipNetMap. + netMap.ips[ipNet.IP.String()] = true +} + +// Get the appropriate array of networks for the given IP version. +func (w *NetSet) getNetMaps(ip net.IP) (netMaps *[]ipNetMap) { + switch { + case ip.To4() != nil: + netMaps = &w.ip4NetMaps + case ip.To16() != nil: + netMaps = &w.ip6NetMaps + default: + panic(fmt.Sprintf("IP (%s) is neither 4-byte nor 16-byte?", ip.String())) + } + + return netMaps +} + +// Hash-set of CIDR networks with the same mask size. +type ipNetMap struct { + mask net.IPMask + ips map[string]bool +} + +// Check if the IP is in any of the CIDR networks contained in this map. +func (m ipNetMap) has(ip net.IP) bool { + // Apply the mask to the IP to remove any irrelevant bits in the IP. + ipMasked := ip.Mask(m.mask) + if ipMasked == nil { + panic(fmt.Sprintf( + "Mismatch in net.IPMask and net.IP protocol version, cannot apply mask %s to %s", + m.mask.String(), ip.String())) + } + + // Check if the masked IP is the same as any of the networks. + if _, ok := m.ips[ipMasked.String()]; ok { + return true + } + return false +} diff --git a/pkg/ip/net_set_test.go b/pkg/ip/net_set_test.go new file mode 100644 index 00000000..7f000a39 --- /dev/null +++ b/pkg/ip/net_set_test.go @@ -0,0 +1,117 @@ +package ip + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmptyNetSet(t *testing.T) { + set := NewNetSet() + + ips := []string{ + "127.0.0.1", + "241.163.75.163", + "189.248.81.89", + "167.75.10.139", + "22.169.8.110", + "::1", + "114d:28ae:60a:7b12:b851:fd49:5ff9:9b9d", + "4e73:55a5:6cd:29c7:5db6:673a:4608:1a3f", + "f6b0:d7f3:8206:b529:ee3b:49ff:1b3b:9aac", + "09d8:a74c:652f:3763:b87d:f068:b593:b588", + } + for _, ip := range ips { + assert.Falsef(t, set.Has(net.ParseIP(ip)), "Empty NetSet must not have %q", ip) + } +} + +func TestV4ContainsEverything(t *testing.T) { + set := NewNetSet() + set.AddIPNet(net.IPNet{IP: net.ParseIP("0.0.0.0"), Mask: net.CIDRMask(0, 32)}) + + ip4s := []string{ + "127.0.0.1", + "250.169.183.192", + "175.125.175.160", + "178.244.137.158", + "190.13.12.194", + } + for _, ip := range ip4s { + assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"0.0.0.0/0\"} must have %q", ip) + } + ip6s := []string{ + "::1", + "7261:ca92:c9a5:4b79:2e:8889:b769:1d62", + "f54f:10cb:e6a4:89f5:3366:1e3e:2d22:f68e", + "d1f3:be6:60d:361:1717:4fe6:9812:1a6c", + "7eaa:2a8:365c:b55f:67cd:96dd:602a:4385", + } + for _, ip := range ip6s { + assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"0.0.0.0/0\"} must not have %q", ip) + } +} + +func TestV6ContainsEverything(t *testing.T) { + set := NewNetSet() + set.AddIPNet(net.IPNet{IP: net.ParseIP("::"), Mask: net.CIDRMask(0, 128)}) + + ip4s := []string{ + "127.0.0.1", + "216.163.140.97", + "175.55.31.226", + "115.112.186.19", + "250.83.241.122", + } + for _, ip := range ip4s { + assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"::/0\"} must not have %q", ip) + } + ip6s := []string{ + "::1", + "fa3b:7f17:7521:d9f4:d855:51f9:4b63:de7f", + "4d47:c29:2479:41ce:69f9:3d33:306a:91c8", + "fe99:f364:f8cd:7a11:838:a36f:b9a1:965", + "fb9e:d809:881d:9cee:533d:1ba5:592e:ea9b", + } + for _, ip := range ip6s { + assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"::/0\"} must have %q", ip) + } +} + +func TestLocalhostOnly(t *testing.T) { + set := NewNetSet() + set.AddIPNet(net.IPNet{IP: net.ParseIP("127.0.0.0"), Mask: net.CIDRMask(8, 32)}) + set.AddIPNet(net.IPNet{IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)}) + + included := []string{ + "127.0.0.1", + "127.196.199.162", + "127.78.85.40", + "127.205.171.240", + "127.121.187.93", + "127.5.217.10", + "127.22.172.251", + "127.12.60.198", + "127.110.240.252", + "::1", + } + for _, ip := range included { + assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"127.0.0.0/8\", \"::1\"} must have %q", ip) + } + excluded := []string{ + "15.65.169.230", + "196.72.207.65", + "43.46.246.121", + "69.147.193.224", + "43.130.74.115", + "8375:b8f9:70bf:a247:f55e:b0c0:c24f:eebe", + "d325:aabf:ef49:2d46:ba47:1aa5:9e57:b0bf", + "d2f6:831:170:e04e:bd6d:7ae7:e14a:71c3", + "ca80:483f:bb7f:df2a:bceb:de5e:6129:9625", + "f3fe:f771:9138:5d63:8e83:7033:2e6f:4762", + } + for _, ip := range excluded { + assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"127.0.0.0/8\", \"::1\"} must not have %q", ip) + } +} diff --git a/pkg/ip/parse_ip_net.go b/pkg/ip/parse_ip_net.go new file mode 100644 index 00000000..9cb37de2 --- /dev/null +++ b/pkg/ip/parse_ip_net.go @@ -0,0 +1,39 @@ +package ip + +import ( + "net" + "strings" +) + +func ParseIPNet(s string) *net.IPNet { + if !strings.ContainsRune(s, '/') { + ip := net.ParseIP(s) + if ip == nil { + return nil + } + + var mask net.IPMask + switch { + case ip.To4() != nil: + mask = net.CIDRMask(32, 32) + case ip.To16() != nil: + mask = net.CIDRMask(128, 128) + default: + return nil + } + + return &net.IPNet{ + IP: ip, + Mask: mask, + } + } + + switch ip, ipNet, err := net.ParseCIDR(s); { + case err != nil: + return nil + case !ipNet.IP.Equal(ip): + return nil + default: + return ipNet + } +} diff --git a/pkg/ip/realclientip.go b/pkg/ip/realclientip.go index 0fb43e9b..b82a3c6e 100644 --- a/pkg/ip/realclientip.go +++ b/pkg/ip/realclientip.go @@ -58,6 +58,14 @@ func (p xForwardedForClientIPParser) GetRealClientIP(h http.Header) (net.IP, err return ip, nil } +// GetClientIP obtains the perceived end-user IP address from headers if p != nil else from req.RemoteAddr. +func GetClientIP(p ipapi.RealClientIPParser, req *http.Request) (net.IP, error) { + if p != nil { + return p.GetRealClientIP(req.Header) + } + return getRemoteIP(req) +} + // getRemoteIP obtains the IP of the low-level connected network host func getRemoteIP(req *http.Request) (net.IP, error) { if ipStr, _, err := net.SplitHostPort(req.RemoteAddr); err != nil { diff --git a/pkg/validation/options.go b/pkg/validation/options.go index 301c5e90..cefbb31a 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -225,6 +225,16 @@ func Validate(o *options.Options) error { }) } + if len(o.TrustedIPs) > 0 && o.ReverseProxy { + fmt.Fprintln(os.Stderr, "WARNING: trusting of IPs with --reverse-proxy poses risks if a header spoofing attack is possible.") + } + + for i, ipStr := range o.TrustedIPs { + if nil == ip.ParseIPNet(ipStr) { + msgs = append(msgs, fmt.Sprintf("trusted_ips[%d] (%s) could not be recognized", i, ipStr)) + } + } + if len(msgs) != 0 { return fmt.Errorf("invalid configuration:\n %s", strings.Join(msgs, "\n ")) diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index 51525555..15761a28 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -2,6 +2,7 @@ package validation import ( "crypto" + "errors" "io/ioutil" "net/url" "os" @@ -352,6 +353,45 @@ func TestRealClientIPHeader(t *testing.T) { assert.Nil(t, o.GetRealClientIPParser()) } +func TestIPCIDRSetOption(t *testing.T) { + tests := []struct { + name string + trustedIPs []string + err error + }{ + { + "TestSomeIPs", + []string{"127.0.0.1", "10.32.0.1/32", "43.36.201.0/24", "::1", "2a12:105:ee7:9234:0:0:0:0/64"}, + nil, + }, { + "TestOverlappingIPs", + []string{"135.180.78.199", "135.180.78.199/32", "d910:a5a1:16f8:ddf5:e5b9:5cef:a65e:41f4", "d910:a5a1:16f8:ddf5:e5b9:5cef:a65e:41f4/128"}, + nil, + }, { + "TestInvalidIPs", + []string{"[::1]", "alkwlkbn/32"}, + errors.New( + "invalid configuration:\n" + + " trusted_ips[0] ([::1]) could not be recognized\n" + + " trusted_ips[1] (alkwlkbn/32) could not be recognized", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := testOptions() + o.TrustedIPs = tt.trustedIPs + err := Validate(o) + if tt.err == nil { + assert.Nil(t, err) + } else { + assert.Equal(t, tt.err.Error(), err.Error()) + } + }) + } +} + func TestProviderCAFilesError(t *testing.T) { file, err := ioutil.TempFile("", "absent.*.crt") assert.NoError(t, err)