git.iko.soy/typeable/oauth2-proxy
on master help sign in
Implements --trusted-ip option (#552)

* Implements --ip-whitelist option * Included IPWhitelist option to allow one-or-more selected CIDR ranges to bypass OAuth2 authentication. * Adds IPWhitelist, a fast lookup table for multiple CIDR ranges. * Renamed IPWhitelist ipCIDRSet * Fixed unessesary pointer usage in ipCIDRSet * Update CHANGELOG.md * Update CHANGELOG.md * Updated to not use err.Error() in printf statements * Imrpoved language for --ip-whitelist descriptions. * Improve IP whitelist options error messages * Clarify options single-host normalization * Wrote a book about ipCIDRSet * Added comment to IsWhitelistedIP in oauthproxy.go * Rewrite oauthproxy test case as table driven * oops * Support whitelisting by low-level remote address * Added more test-cases, improved descriptions * Move ip_cidr_set.go to pkg/ip/net_set.go * Add more whitelist test use cases. * Oops * Use subtests for TestIPWhitelist * Add minimal tests for ip.NetSet * Use switch statment * Renamed ip-whitelist to whitelist-ip * Update documentation with a warning. * Update pkg/apis/options/options.go * Update CHANGELOG.md Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Update pkg/ip/net_set_test.go Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Update pkg/ip/net_set_test.go Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Update pkg/ip/net_set_test.go Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Apply suggestions from code review Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * fix fmt * Move ParseIPNet into abstraction * Add warning in case of --reverse-proxy * Update pkg/validation/options_test.go * Rename --whitelist-ip to --trusted-ip * Update oauthproxy.go Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * fix Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk>

commit64ae31b5a098375cbdda8fcfe06ccc6da9f94740 authorIsabelle COWAN-BERGMAN <Izzette@users.noreply.github.com> dateSat 11 Jul 2020, 12:10 (6 years ago) parente6903d8
12 files changed+541−17
CHANGELOG.md+1 contrib/oauth2-proxy_autocomplete.sh+1−1 docs/configuration/configuration.md+1 oauthproxy.go+32−1 oauthproxy_test.go+163 pkg/apis/options/options.go+17−15 pkg/ip/net_set.go+112 pkg/ip/net_set_test.go+117 pkg/ip/parse_ip_net.go+39 pkg/ip/realclientip.go+8 pkg/validation/options.go+10 pkg/validation/options_test.go+40
CHANGELOG.md+1
@@ -93,6 +93,7 @@
9393
9494## Changes since v5.1.1
9595
96+- [#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)
9697- [GHSA-5m6c-jp6f-2vcv](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-5m6c-jp6f-2vcv) New OpenRedirect cases have been found (@JoelSpeed)
9798- [#639](https://github.com/oauth2-proxy/oauth2-proxy/pull/639) Change how gitlab-group is parsed on options (@linuxgemini)
9899- [#615](https://github.com/oauth2-proxy/oauth2-proxy/pull/615) Kubernetes example based on Kind cluster and Nginx ingress (@EvgeniGordeev)
contrib/oauth2-proxy_autocomplete.sh+1−1
@@ -24,7 +24,7 @@ _oauth2_proxy() {
2424 COMPREPLY=( $(compgen -W 'X-Real-IP X-Forwarded-For X-ProxyUser-IP' -- ${cur}) )
2525 return 0
2626 ;;
27− --@(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))
27+ --@(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))
2828 return 0
2929 ;;
3030 esac
docs/configuration/configuration.md+1
@@ -129,6 +129,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
129129| `--validate-url` | string | Access token validation endpoint | |
130130| `--version` | n/a | print version string | |
131131| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (eg `.example.com`) | |
132+| `--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. | |
132133
133134Note: 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:*`.
134135
oauthproxy.go+32−1
@@ -117,6 +117,7 @@ type OAuthProxy struct {
117117 compiledRegex []*regexp.Regexp
118118 templates *template.Template
119119 realClientIPParser ipapi.RealClientIPParser
120+ trustedIPs *ip.NetSet
120121 Banner string
121122 Footer string
122123}
@@ -303,6 +304,15 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
303304
304305 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)
305306
307+ trustedIPs := ip.NewNetSet()
308+ for _, ipStr := range opts.TrustedIPs {
309+ if ipNet := ip.ParseIPNet(ipStr); ipNet != nil {
310+ trustedIPs.AddIPNet(*ipNet)
311+ } else {
312+ return nil, fmt.Errorf("could not parse IP network (%s)", ipStr)
313+ }
314+ }
315+
306316 return &OAuthProxy{
307317 CookieName: opts.Cookie.Name,
308318 CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
@@ -349,6 +359,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
349359 PreferEmailToUser: opts.PreferEmailToUser,
350360 SkipProviderButton: opts.SkipProviderButton,
351361 templates: loadTemplates(opts.CustomTemplatesDir),
362+ trustedIPs: trustedIPs,
352363 Banner: opts.Banner,
353364 Footer: opts.Footer,
354365 }, nil
@@ -650,7 +661,7 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
650661// IsWhitelistedRequest is used to check if auth should be skipped for this request
651662func (p *OAuthProxy) IsWhitelistedRequest(req *http.Request) bool {
652663 isPreflightRequestAllowed := p.skipAuthPreflight && req.Method == "OPTIONS"
653− return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path)
664+ return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path) || p.IsTrustedIP(req)
654665}
655666
656667// IsWhitelistedPath is used to check if the request path is allowed without auth
@@ -678,6 +689,26 @@ func prepareNoCache(w http.ResponseWriter) {
678689 }
679690}
680691
692+// IsTrustedIP is used to check if a request comes from a trusted client IP address.
693+func (p *OAuthProxy) IsTrustedIP(req *http.Request) bool {
694+ if p.trustedIPs == nil {
695+ return false
696+ }
697+
698+ remoteAddr, err := ip.GetClientIP(p.realClientIPParser, req)
699+ if err != nil {
700+ logger.Printf("Error obtaining real IP for trusted IP list: %v", err)
701+ // Possibly spoofed X-Real-IP header
702+ return false
703+ }
704+
705+ if remoteAddr == nil {
706+ return false
707+ }
708+
709+ return p.trustedIPs.Has(remoteAddr)
710+}
711+
681712func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
682713 if req.URL.Path != p.AuthOnlyPath && strings.HasPrefix(req.URL.Path, p.ProxyPrefix) {
683714 prepareNoCache(rw)
oauthproxy_test.go+163
@@ -1892,3 +1892,166 @@ func baseTestOptions() *options.Options {
18921892 opts.EmailDomains = []string{"*"}
18931893 return opts
18941894}
1895+
1896+func TestTrustedIPs(t *testing.T) {
1897+ tests := []struct {
1898+ name string
1899+ trustedIPs []string
1900+ reverseProxy bool
1901+ realClientIPHeader string
1902+ req *http.Request
1903+ expectTrusted bool
1904+ }{
1905+ // Check unconfigured behavior.
1906+ {
1907+ name: "Default",
1908+ trustedIPs: nil,
1909+ reverseProxy: false,
1910+ realClientIPHeader: "X-Real-IP", // Default value
1911+ req: func() *http.Request {
1912+ req, _ := http.NewRequest("GET", "/", nil)
1913+ return req
1914+ }(),
1915+ expectTrusted: false,
1916+ },
1917+ // Check using req.RemoteAddr (Options.ReverseProxy == false).
1918+ {
1919+ name: "WithRemoteAddr",
1920+ trustedIPs: []string{"127.0.0.1"},
1921+ reverseProxy: false,
1922+ realClientIPHeader: "X-Real-IP", // Default value
1923+ req: func() *http.Request {
1924+ req, _ := http.NewRequest("GET", "/", nil)
1925+ req.RemoteAddr = "127.0.0.1:43670"
1926+ return req
1927+ }(),
1928+ expectTrusted: true,
1929+ },
1930+ // Check ignores req.RemoteAddr match when behind a reverse proxy / missing header.
1931+ {
1932+ name: "IgnoresRemoteAddrInReverseProxyMode",
1933+ trustedIPs: []string{"127.0.0.1"},
1934+ reverseProxy: true,
1935+ realClientIPHeader: "X-Real-IP", // Default value
1936+ req: func() *http.Request {
1937+ req, _ := http.NewRequest("GET", "/", nil)
1938+ req.RemoteAddr = "127.0.0.1:44324"
1939+ return req
1940+ }(),
1941+ expectTrusted: false,
1942+ },
1943+ // Check successful trusting of localhost in IPv4.
1944+ {
1945+ name: "TrustsLocalhostInReverseProxyMode",
1946+ trustedIPs: []string{"127.0.0.0/8", "::1"},
1947+ reverseProxy: true,
1948+ realClientIPHeader: "X-Forwarded-For",
1949+ req: func() *http.Request {
1950+ req, _ := http.NewRequest("GET", "/", nil)
1951+ req.Header.Add("X-Forwarded-For", "127.0.0.1")
1952+ return req
1953+ }(),
1954+ expectTrusted: true,
1955+ },
1956+ // Check successful trusting of localhost in IPv6.
1957+ {
1958+ name: "TrustsIP6LocalostInReverseProxyMode",
1959+ trustedIPs: []string{"127.0.0.0/8", "::1"},
1960+ reverseProxy: true,
1961+ realClientIPHeader: "X-Forwarded-For",
1962+ req: func() *http.Request {
1963+ req, _ := http.NewRequest("GET", "/", nil)
1964+ req.Header.Add("X-Forwarded-For", "::1")
1965+ return req
1966+ }(),
1967+ expectTrusted: true,
1968+ },
1969+ // Check does not trust random IPv4 address.
1970+ {
1971+ name: "DoesNotTrustRandomIP4Address",
1972+ trustedIPs: []string{"127.0.0.0/8", "::1"},
1973+ reverseProxy: true,
1974+ realClientIPHeader: "X-Forwarded-For",
1975+ req: func() *http.Request {
1976+ req, _ := http.NewRequest("GET", "/", nil)
1977+ req.Header.Add("X-Forwarded-For", "12.34.56.78")
1978+ return req
1979+ }(),
1980+ expectTrusted: false,
1981+ },
1982+ // Check does not trust random IPv6 address.
1983+ {
1984+ name: "DoesNotTrustRandomIP6Address",
1985+ trustedIPs: []string{"127.0.0.0/8", "::1"},
1986+ reverseProxy: true,
1987+ realClientIPHeader: "X-Forwarded-For",
1988+ req: func() *http.Request {
1989+ req, _ := http.NewRequest("GET", "/", nil)
1990+ req.Header.Add("X-Forwarded-For", "::2")
1991+ return req
1992+ }(),
1993+ expectTrusted: false,
1994+ },
1995+ // Check respects correct header.
1996+ {
1997+ name: "RespectsCorrectHeaderInReverseProxyMode",
1998+ trustedIPs: []string{"127.0.0.0/8", "::1"},
1999+ reverseProxy: true,
2000+ realClientIPHeader: "X-Forwarded-For",
2001+ req: func() *http.Request {
2002+ req, _ := http.NewRequest("GET", "/", nil)
2003+ req.Header.Add("X-Real-IP", "::1")
2004+ return req
2005+ }(),
2006+ expectTrusted: false,
2007+ },
2008+ // Check doesn't trust if garbage is provided.
2009+ {
2010+ name: "DoesNotTrustGarbageInReverseProxyMode",
2011+ trustedIPs: []string{"127.0.0.0/8", "::1"},
2012+ reverseProxy: true,
2013+ realClientIPHeader: "X-Forwarded-For",
2014+ req: func() *http.Request {
2015+ req, _ := http.NewRequest("GET", "/", nil)
2016+ req.Header.Add("X-Forwarded-For", "adsfljk29242as!!")
2017+ return req
2018+ }(),
2019+ expectTrusted: false,
2020+ },
2021+ // Check doesn't trust if garbage is provided (no reverse-proxy).
2022+ {
2023+ name: "DoesNotTrustGarbage",
2024+ trustedIPs: []string{"127.0.0.0/8", "::1"},
2025+ reverseProxy: false,
2026+ realClientIPHeader: "X-Real-IP",
2027+ req: func() *http.Request {
2028+ req, _ := http.NewRequest("GET", "/", nil)
2029+ req.RemoteAddr = "adsfljk29242as!!"
2030+ return req
2031+ }(),
2032+ expectTrusted: false,
2033+ },
2034+ }
2035+
2036+ for _, tt := range tests {
2037+ t.Run(tt.name, func(t *testing.T) {
2038+ opts := baseTestOptions()
2039+ opts.Upstreams = []string{"static://200"}
2040+ opts.TrustedIPs = tt.trustedIPs
2041+ opts.ReverseProxy = tt.reverseProxy
2042+ opts.RealClientIPHeader = tt.realClientIPHeader
2043+ validation.Validate(opts)
2044+
2045+ proxy, err := NewOAuthProxy(opts, func(string) bool { return true })
2046+ assert.NoError(t, err)
2047+ rw := httptest.NewRecorder()
2048+
2049+ proxy.ServeHTTP(rw, tt.req)
2050+ if tt.expectTrusted {
2051+ assert.Equal(t, 200, rw.Code)
2052+ } else {
2053+ assert.Equal(t, 403, rw.Code)
2054+ }
2055+ })
2056+ }
2057+}
pkg/apis/options/options.go+17−15
@@ -21,21 +21,22 @@ type SignatureData struct {
2121// Options holds Configuration Options that can be set by Command Line Flag,
2222// or Config File
2323type Options struct {
24− ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy_prefix"`
25− PingPath string `flag:"ping-path" cfg:"ping_path"`
26− PingUserAgent string `flag:"ping-user-agent" cfg:"ping_user_agent"`
27− ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"`
28− HTTPAddress string `flag:"http-address" cfg:"http_address"`
29− HTTPSAddress string `flag:"https-address" cfg:"https_address"`
30− ReverseProxy bool `flag:"reverse-proxy" cfg:"reverse_proxy"`
31− RealClientIPHeader string `flag:"real-client-ip-header" cfg:"real_client_ip_header"`
32− ForceHTTPS bool `flag:"force-https" cfg:"force_https"`
33− RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"`
34− ClientID string `flag:"client-id" cfg:"client_id"`
35− ClientSecret string `flag:"client-secret" cfg:"client_secret"`
36− ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"`
37− TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"`
38− TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"`
24+ ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy_prefix"`
25+ PingPath string `flag:"ping-path" cfg:"ping_path"`
26+ PingUserAgent string `flag:"ping-user-agent" cfg:"ping_user_agent"`
27+ ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"`
28+ HTTPAddress string `flag:"http-address" cfg:"http_address"`
29+ HTTPSAddress string `flag:"https-address" cfg:"https_address"`
30+ ReverseProxy bool `flag:"reverse-proxy" cfg:"reverse_proxy"`
31+ RealClientIPHeader string `flag:"real-client-ip-header" cfg:"real_client_ip_header"`
32+ TrustedIPs []string `flag:"trusted-ip" cfg:"trusted_ips"`
33+ ForceHTTPS bool `flag:"force-https" cfg:"force_https"`
34+ RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"`
35+ ClientID string `flag:"client-id" cfg:"client_id"`
36+ ClientSecret string `flag:"client-secret" cfg:"client_secret"`
37+ ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"`
38+ TLSCertFile string `flag:"tls-cert-file" cfg:"tls_cert_file"`
39+ TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"`
3940
4041 AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
4142 KeycloakGroup string `flag:"keycloak-group" cfg:"keycloak_group"`
@@ -186,6 +187,7 @@ func NewFlagSet() *pflag.FlagSet {
186187 flagSet.String("https-address", ":443", "<addr>:<port> to listen on for HTTPS clients")
187188 flagSet.Bool("reverse-proxy", false, "are we running behind a reverse proxy, controls whether headers like X-Real-Ip are accepted")
188189 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)")
190+ 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.")
189191 flagSet.Bool("force-https", false, "force HTTPS redirect for HTTP requests")
190192 flagSet.String("tls-cert-file", "", "path to certificate file")
191193 flagSet.String("tls-key-file", "", "path to private key file")
pkg/ip/net_set.gonew file+112
@@ -0,0 +1,112 @@
1+package ip
2+
3+import (
4+ "fmt"
5+ "net"
6+)
7+
8+// Fast lookup table for intersection of a single IP address within a collection of CIDR networks.
9+//
10+// Supports 4-byte (IPv4) and 16-byte (IPv6) networks.
11+//
12+// Provides O(1) best-case, O(log(n)) worst-case performance.
13+// In practice netmasks included will generally only be of standard lengths:
14+// - /8, /16, /24, and /32 for IPv4
15+// - /64 and /128 for IPv6.
16+// As a result, typical lookup times will lean closer to best-case rather than worst-case even when most of the internet
17+// is included.
18+type NetSet struct {
19+ ip4NetMaps []ipNetMap
20+ ip6NetMaps []ipNetMap
21+}
22+
23+// Create a new NetSet with all of the provided networks.
24+func NewNetSet() *NetSet {
25+ return &NetSet{
26+ ip4NetMaps: make([]ipNetMap, 0),
27+ ip6NetMaps: make([]ipNetMap, 0),
28+ }
29+}
30+
31+// Check if `ip` is in the set, true if within the set otherwise false.
32+func (w *NetSet) Has(ip net.IP) bool {
33+ netMaps := w.getNetMaps(ip)
34+
35+ // Check all ipNetMaps for intersection with `ip`.
36+ for _, netMap := range *netMaps {
37+ if netMap.has(ip) {
38+ return true
39+ }
40+ }
41+ return false
42+}
43+
44+// Add an CIDR network to the set.
45+func (w *NetSet) AddIPNet(ipNet net.IPNet) {
46+ netMaps := w.getNetMaps(ipNet.IP)
47+
48+ // Determine the size / number of ones in the CIDR network mask.
49+ ones, _ := ipNet.Mask.Size()
50+
51+ var netMap *ipNetMap
52+
53+ // Search for the ipNetMap containing networks with the same number of ones.
54+ for i := 0; len(*netMaps) > i; i++ {
55+ if netMapOnes, _ := (*netMaps)[i].mask.Size(); netMapOnes == ones {
56+ netMap = &(*netMaps)[i]
57+ break
58+ }
59+ }
60+
61+ // Create a new ipNetMap if none with this number of ones have been created yet.
62+ if netMap == nil {
63+ netMap = &ipNetMap{
64+ mask: ipNet.Mask,
65+ ips: make(map[string]bool),
66+ }
67+ *netMaps = append(*netMaps, *netMap)
68+ // Recurse once now that there exists an netMap.
69+ w.AddIPNet(ipNet)
70+ return
71+ }
72+
73+ // Add the IP to the ipNetMap.
74+ netMap.ips[ipNet.IP.String()] = true
75+}
76+
77+// Get the appropriate array of networks for the given IP version.
78+func (w *NetSet) getNetMaps(ip net.IP) (netMaps *[]ipNetMap) {
79+ switch {
80+ case ip.To4() != nil:
81+ netMaps = &w.ip4NetMaps
82+ case ip.To16() != nil:
83+ netMaps = &w.ip6NetMaps
84+ default:
85+ panic(fmt.Sprintf("IP (%s) is neither 4-byte nor 16-byte?", ip.String()))
86+ }
87+
88+ return netMaps
89+}
90+
91+// Hash-set of CIDR networks with the same mask size.
92+type ipNetMap struct {
93+ mask net.IPMask
94+ ips map[string]bool
95+}
96+
97+// Check if the IP is in any of the CIDR networks contained in this map.
98+func (m ipNetMap) has(ip net.IP) bool {
99+ // Apply the mask to the IP to remove any irrelevant bits in the IP.
100+ ipMasked := ip.Mask(m.mask)
101+ if ipMasked == nil {
102+ panic(fmt.Sprintf(
103+ "Mismatch in net.IPMask and net.IP protocol version, cannot apply mask %s to %s",
104+ m.mask.String(), ip.String()))
105+ }
106+
107+ // Check if the masked IP is the same as any of the networks.
108+ if _, ok := m.ips[ipMasked.String()]; ok {
109+ return true
110+ }
111+ return false
112+}
pkg/ip/net_set_test.gonew file+117
@@ -0,0 +1,117 @@
1+package ip
2+
3+import (
4+ "net"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestEmptyNetSet(t *testing.T) {
11+ set := NewNetSet()
12+
13+ ips := []string{
14+ "127.0.0.1",
15+ "241.163.75.163",
16+ "189.248.81.89",
17+ "167.75.10.139",
18+ "22.169.8.110",
19+ "::1",
20+ "114d:28ae:60a:7b12:b851:fd49:5ff9:9b9d",
21+ "4e73:55a5:6cd:29c7:5db6:673a:4608:1a3f",
22+ "f6b0:d7f3:8206:b529:ee3b:49ff:1b3b:9aac",
23+ "09d8:a74c:652f:3763:b87d:f068:b593:b588",
24+ }
25+ for _, ip := range ips {
26+ assert.Falsef(t, set.Has(net.ParseIP(ip)), "Empty NetSet must not have %q", ip)
27+ }
28+}
29+
30+func TestV4ContainsEverything(t *testing.T) {
31+ set := NewNetSet()
32+ set.AddIPNet(net.IPNet{IP: net.ParseIP("0.0.0.0"), Mask: net.CIDRMask(0, 32)})
33+
34+ ip4s := []string{
35+ "127.0.0.1",
36+ "250.169.183.192",
37+ "175.125.175.160",
38+ "178.244.137.158",
39+ "190.13.12.194",
40+ }
41+ for _, ip := range ip4s {
42+ assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"0.0.0.0/0\"} must have %q", ip)
43+ }
44+ ip6s := []string{
45+ "::1",
46+ "7261:ca92:c9a5:4b79:2e:8889:b769:1d62",
47+ "f54f:10cb:e6a4:89f5:3366:1e3e:2d22:f68e",
48+ "d1f3:be6:60d:361:1717:4fe6:9812:1a6c",
49+ "7eaa:2a8:365c:b55f:67cd:96dd:602a:4385",
50+ }
51+ for _, ip := range ip6s {
52+ assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"0.0.0.0/0\"} must not have %q", ip)
53+ }
54+}
55+
56+func TestV6ContainsEverything(t *testing.T) {
57+ set := NewNetSet()
58+ set.AddIPNet(net.IPNet{IP: net.ParseIP("::"), Mask: net.CIDRMask(0, 128)})
59+
60+ ip4s := []string{
61+ "127.0.0.1",
62+ "216.163.140.97",
63+ "175.55.31.226",
64+ "115.112.186.19",
65+ "250.83.241.122",
66+ }
67+ for _, ip := range ip4s {
68+ assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"::/0\"} must not have %q", ip)
69+ }
70+ ip6s := []string{
71+ "::1",
72+ "fa3b:7f17:7521:d9f4:d855:51f9:4b63:de7f",
73+ "4d47:c29:2479:41ce:69f9:3d33:306a:91c8",
74+ "fe99:f364:f8cd:7a11:838:a36f:b9a1:965",
75+ "fb9e:d809:881d:9cee:533d:1ba5:592e:ea9b",
76+ }
77+ for _, ip := range ip6s {
78+ assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"::/0\"} must have %q", ip)
79+ }
80+}
81+
82+func TestLocalhostOnly(t *testing.T) {
83+ set := NewNetSet()
84+ set.AddIPNet(net.IPNet{IP: net.ParseIP("127.0.0.0"), Mask: net.CIDRMask(8, 32)})
85+ set.AddIPNet(net.IPNet{IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)})
86+
87+ included := []string{
88+ "127.0.0.1",
89+ "127.196.199.162",
90+ "127.78.85.40",
91+ "127.205.171.240",
92+ "127.121.187.93",
93+ "127.5.217.10",
94+ "127.22.172.251",
95+ "127.12.60.198",
96+ "127.110.240.252",
97+ "::1",
98+ }
99+ for _, ip := range included {
100+ assert.Truef(t, set.Has(net.ParseIP(ip)), "NetSet{\"127.0.0.0/8\", \"::1\"} must have %q", ip)
101+ }
102+ excluded := []string{
103+ "15.65.169.230",
104+ "196.72.207.65",
105+ "43.46.246.121",
106+ "69.147.193.224",
107+ "43.130.74.115",
108+ "8375:b8f9:70bf:a247:f55e:b0c0:c24f:eebe",
109+ "d325:aabf:ef49:2d46:ba47:1aa5:9e57:b0bf",
110+ "d2f6:831:170:e04e:bd6d:7ae7:e14a:71c3",
111+ "ca80:483f:bb7f:df2a:bceb:de5e:6129:9625",
112+ "f3fe:f771:9138:5d63:8e83:7033:2e6f:4762",
113+ }
114+ for _, ip := range excluded {
115+ assert.Falsef(t, set.Has(net.ParseIP(ip)), "NetSet{\"127.0.0.0/8\", \"::1\"} must not have %q", ip)
116+ }
117+}
pkg/ip/parse_ip_net.gonew file+39
@@ -0,0 +1,39 @@
1+package ip
2+
3+import (
4+ "net"
5+ "strings"
6+)
7+
8+func ParseIPNet(s string) *net.IPNet {
9+ if !strings.ContainsRune(s, '/') {
10+ ip := net.ParseIP(s)
11+ if ip == nil {
12+ return nil
13+ }
14+
15+ var mask net.IPMask
16+ switch {
17+ case ip.To4() != nil:
18+ mask = net.CIDRMask(32, 32)
19+ case ip.To16() != nil:
20+ mask = net.CIDRMask(128, 128)
21+ default:
22+ return nil
23+ }
24+
25+ return &net.IPNet{
26+ IP: ip,
27+ Mask: mask,
28+ }
29+ }
30+
31+ switch ip, ipNet, err := net.ParseCIDR(s); {
32+ case err != nil:
33+ return nil
34+ case !ipNet.IP.Equal(ip):
35+ return nil
36+ default:
37+ return ipNet
38+ }
39+}
pkg/ip/realclientip.go+8
@@ -58,6 +58,14 @@ func (p xForwardedForClientIPParser) GetRealClientIP(h http.Header) (net.IP, err
5858 return ip, nil
5959}
6060
61+// GetClientIP obtains the perceived end-user IP address from headers if p != nil else from req.RemoteAddr.
62+func GetClientIP(p ipapi.RealClientIPParser, req *http.Request) (net.IP, error) {
63+ if p != nil {
64+ return p.GetRealClientIP(req.Header)
65+ }
66+ return getRemoteIP(req)
67+}
68+
6169// getRemoteIP obtains the IP of the low-level connected network host
6270func getRemoteIP(req *http.Request) (net.IP, error) {
6371 if ipStr, _, err := net.SplitHostPort(req.RemoteAddr); err != nil {
pkg/validation/options.go+10
@@ -225,6 +225,16 @@ func Validate(o *options.Options) error {
225225 })
226226 }
227227
228+ if len(o.TrustedIPs) > 0 && o.ReverseProxy {
229+ fmt.Fprintln(os.Stderr, "WARNING: trusting of IPs with --reverse-proxy poses risks if a header spoofing attack is possible.")
230+ }
231+
232+ for i, ipStr := range o.TrustedIPs {
233+ if nil == ip.ParseIPNet(ipStr) {
234+ msgs = append(msgs, fmt.Sprintf("trusted_ips[%d] (%s) could not be recognized", i, ipStr))
235+ }
236+ }
237+
228238 if len(msgs) != 0 {
229239 return fmt.Errorf("invalid configuration:\n %s",
230240 strings.Join(msgs, "\n "))
pkg/validation/options_test.go+40
@@ -2,6 +2,7 @@ package validation
22
33import (
44 "crypto"
5+ "errors"
56 "io/ioutil"
67 "net/url"
78 "os"
@@ -352,6 +353,45 @@ func TestRealClientIPHeader(t *testing.T) {
352353 assert.Nil(t, o.GetRealClientIPParser())
353354}
354355
356+func TestIPCIDRSetOption(t *testing.T) {
357+ tests := []struct {
358+ name string
359+ trustedIPs []string
360+ err error
361+ }{
362+ {
363+ "TestSomeIPs",
364+ []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"},
365+ nil,
366+ }, {
367+ "TestOverlappingIPs",
368+ []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"},
369+ nil,
370+ }, {
371+ "TestInvalidIPs",
372+ []string{"[::1]", "alkwlkbn/32"},
373+ errors.New(
374+ "invalid configuration:\n" +
375+ " trusted_ips[0] ([::1]) could not be recognized\n" +
376+ " trusted_ips[1] (alkwlkbn/32) could not be recognized",
377+ ),
378+ },
379+ }
380+
381+ for _, tt := range tests {
382+ t.Run(tt.name, func(t *testing.T) {
383+ o := testOptions()
384+ o.TrustedIPs = tt.trustedIPs
385+ err := Validate(o)
386+ if tt.err == nil {
387+ assert.Nil(t, err)
388+ } else {
389+ assert.Equal(t, tt.err.Error(), err.Error())
390+ }
391+ })
392+ }
393+}
394+
355395func TestProviderCAFilesError(t *testing.T) {
356396 file, err := ioutil.TempFile("", "absent.*.crt")
357397 assert.NoError(t, err)