Merge pull request #1226 from oauth2-proxy/app-redirect
Move app redirection logic to its own package
This commit is contained in:
		
						commit
						c2325ecbd5
					
				|  | @ -8,6 +8,7 @@ | ||||||
| 
 | 
 | ||||||
| ## Changes since v7.1.3 | ## Changes since v7.1.3 | ||||||
| 
 | 
 | ||||||
|  | - [#1226](https://github.com/oauth2-proxy/oauth2-proxy/pull/1226) Move app redirection logic to its own package (@JoelSpeed) | ||||||
| - [#1128](https://github.com/oauth2-proxy/oauth2-proxy/pull/1128) Use gorilla mux for OAuth Proxy routing (@JoelSpeed) | - [#1128](https://github.com/oauth2-proxy/oauth2-proxy/pull/1128) Use gorilla mux for OAuth Proxy routing (@JoelSpeed) | ||||||
| - [#1238](https://github.com/oauth2-proxy/oauth2-proxy/pull/1238) Added ADFS provider (@samirachoadi) | - [#1238](https://github.com/oauth2-proxy/oauth2-proxy/pull/1238) Added ADFS provider (@samirachoadi) | ||||||
| - [#1227](https://github.com/oauth2-proxy/oauth2-proxy/pull/1227) Fix Refresh Session not working for multiple cookies (@rishi1111) | - [#1227](https://github.com/oauth2-proxy/oauth2-proxy/pull/1227) Fix Refresh Session not working for multiple cookies (@rishi1111) | ||||||
|  |  | ||||||
							
								
								
									
										239
									
								
								oauthproxy.go
								
								
								
								
							
							
						
						
									
										239
									
								
								oauthproxy.go
								
								
								
								
							|  | @ -22,6 +22,7 @@ import ( | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||||
| 	sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | 	sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter" | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/redirect" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies" | ||||||
| 	proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http" | 	proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http" | ||||||
|  | @ -55,10 +56,6 @@ var ( | ||||||
| 
 | 
 | ||||||
| 	// ErrAccessDenied means the user should receive a 401 Unauthorized response
 | 	// ErrAccessDenied means the user should receive a 401 Unauthorized response
 | ||||||
| 	ErrAccessDenied = errors.New("access denied") | 	ErrAccessDenied = errors.New("access denied") | ||||||
| 
 |  | ||||||
| 	// Used to check final redirects are not susceptible to open redirects.
 |  | ||||||
| 	// Matches //, /\ and both of these with whitespace in between (eg / / or / \).
 |  | ||||||
| 	invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`) |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // allowedRoute manages method + path based allowlists
 | // allowedRoute manages method + path based allowlists
 | ||||||
|  | @ -87,13 +84,15 @@ type OAuthProxy struct { | ||||||
| 	realClientIPParser  ipapi.RealClientIPParser | 	realClientIPParser  ipapi.RealClientIPParser | ||||||
| 	trustedIPs          *ip.NetSet | 	trustedIPs          *ip.NetSet | ||||||
| 
 | 
 | ||||||
| 	sessionChain  alice.Chain | 	sessionChain      alice.Chain | ||||||
| 	headersChain  alice.Chain | 	headersChain      alice.Chain | ||||||
| 	preAuthChain  alice.Chain | 	preAuthChain      alice.Chain | ||||||
| 	pageWriter    pagewriter.Writer | 	pageWriter        pagewriter.Writer | ||||||
| 	server        proxyhttp.Server | 	server            proxyhttp.Server | ||||||
| 	upstreamProxy http.Handler | 	upstreamProxy     http.Handler | ||||||
| 	serveMux      *mux.Router | 	serveMux          *mux.Router | ||||||
|  | 	redirectValidator redirect.Validator | ||||||
|  | 	appDirector       redirect.AppDirector | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewOAuthProxy creates a new instance of OAuthProxy from the options provided
 | // NewOAuthProxy creates a new instance of OAuthProxy from the options provided
 | ||||||
|  | @ -176,6 +175,12 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr | ||||||
| 		return nil, fmt.Errorf("could not build headers chain: %v", err) | 		return nil, fmt.Errorf("could not build headers chain: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	redirectValidator := redirect.NewValidator(opts.WhitelistDomains) | ||||||
|  | 	appDirector := redirect.NewAppDirector(redirect.AppDirectorOpts{ | ||||||
|  | 		ProxyPrefix: opts.ProxyPrefix, | ||||||
|  | 		Validator:   redirectValidator, | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
| 	p := &OAuthProxy{ | 	p := &OAuthProxy{ | ||||||
| 		CookieOptions: &opts.Cookie, | 		CookieOptions: &opts.Cookie, | ||||||
| 		Validator:     validator, | 		Validator:     validator, | ||||||
|  | @ -200,6 +205,8 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr | ||||||
| 		preAuthChain:       preAuthChain, | 		preAuthChain:       preAuthChain, | ||||||
| 		pageWriter:         pageWriter, | 		pageWriter:         pageWriter, | ||||||
| 		upstreamProxy:      upstreamProxy, | 		upstreamProxy:      upstreamProxy, | ||||||
|  | 		redirectValidator:  redirectValidator, | ||||||
|  | 		appDirector:        appDirector, | ||||||
| 	} | 	} | ||||||
| 	p.buildServeMux(opts.ProxyPrefix) | 	p.buildServeMux(opts.ProxyPrefix) | ||||||
| 
 | 
 | ||||||
|  | @ -465,59 +472,13 @@ func (p *OAuthProxy) SaveSession(rw http.ResponseWriter, req *http.Request, s *s | ||||||
| 	return p.sessionStore.Save(rw, req, s) | 	return p.sessionStore.Save(rw, req, s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // IsValidRedirect checks whether the redirect URL is whitelisted
 |  | ||||||
| func (p *OAuthProxy) IsValidRedirect(redirect string) bool { |  | ||||||
| 	switch { |  | ||||||
| 	case redirect == "": |  | ||||||
| 		// The user didn't specify a redirect, should fallback to `/`
 |  | ||||||
| 		return false |  | ||||||
| 	case strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") && !invalidRedirectRegex.MatchString(redirect): |  | ||||||
| 		return true |  | ||||||
| 	case strings.HasPrefix(redirect, "http://") || strings.HasPrefix(redirect, "https://"): |  | ||||||
| 		redirectURL, err := url.Parse(redirect) |  | ||||||
| 		if err != nil { |  | ||||||
| 			logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect) |  | ||||||
| 			return false |  | ||||||
| 		} |  | ||||||
| 		redirectHostname := redirectURL.Hostname() |  | ||||||
| 
 |  | ||||||
| 		for _, allowedDomain := range p.whitelistDomains { |  | ||||||
| 			allowedHost, allowedPort := splitHostPort(allowedDomain) |  | ||||||
| 			if allowedHost == "" { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if redirectHostname == strings.TrimPrefix(allowedHost, ".") || |  | ||||||
| 				(strings.HasPrefix(allowedHost, ".") && |  | ||||||
| 					strings.HasSuffix(redirectHostname, allowedHost)) { |  | ||||||
| 				// the domain names match, now validate the ports
 |  | ||||||
| 				// if the whitelisted domain's port is '*', allow all ports
 |  | ||||||
| 				// if the whitelisted domain contains a specific port, only allow that port
 |  | ||||||
| 				// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
 |  | ||||||
| 				redirectPort := redirectURL.Port() |  | ||||||
| 				if allowedPort == "*" || |  | ||||||
| 					allowedPort == redirectPort || |  | ||||||
| 					(allowedPort == "" && redirectPort == "") { |  | ||||||
| 					return true |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect) |  | ||||||
| 		return false |  | ||||||
| 	default: |  | ||||||
| 		logger.Printf("Rejecting invalid redirect %q: not an absolute or relative URL", redirect) |  | ||||||
| 		return false |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { | func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { | ||||||
| 	p.serveMux.ServeHTTP(rw, req) | 	p.serveMux.ServeHTTP(rw, req) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ErrorPage writes an error response
 | // ErrorPage writes an error response
 | ||||||
| func (p *OAuthProxy) ErrorPage(rw http.ResponseWriter, req *http.Request, code int, appError string, messages ...interface{}) { | func (p *OAuthProxy) ErrorPage(rw http.ResponseWriter, req *http.Request, code int, appError string, messages ...interface{}) { | ||||||
| 	redirectURL, err := p.getAppRedirect(req) | 	redirectURL, err := p.appDirector.GetRedirect(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Errorf("Error obtaining redirect: %v", err) | 		logger.Errorf("Error obtaining redirect: %v", err) | ||||||
| 	} | 	} | ||||||
|  | @ -582,7 +543,7 @@ func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code | ||||||
| 	} | 	} | ||||||
| 	rw.WriteHeader(code) | 	rw.WriteHeader(code) | ||||||
| 
 | 
 | ||||||
| 	redirectURL, err := p.getAppRedirect(req) | 	redirectURL, err := p.appDirector.GetRedirect(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Errorf("Error obtaining redirect: %v", err) | 		logger.Errorf("Error obtaining redirect: %v", err) | ||||||
| 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | ||||||
|  | @ -617,7 +578,7 @@ func (p *OAuthProxy) ManualSignIn(req *http.Request) (string, bool) { | ||||||
| 
 | 
 | ||||||
| // SignIn serves a page prompting users to sign in
 | // SignIn serves a page prompting users to sign in
 | ||||||
| func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) { | func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) { | ||||||
| 	redirect, err := p.getAppRedirect(req) | 	redirect, err := p.appDirector.GetRedirect(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Errorf("Error obtaining redirect: %v", err) | 		logger.Errorf("Error obtaining redirect: %v", err) | ||||||
| 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | ||||||
|  | @ -681,7 +642,7 @@ func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) { | ||||||
| 
 | 
 | ||||||
| // SignOut sends a response to clear the authentication cookie
 | // SignOut sends a response to clear the authentication cookie
 | ||||||
| func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) { | func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) { | ||||||
| 	redirect, err := p.getAppRedirect(req) | 	redirect, err := p.appDirector.GetRedirect(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Errorf("Error obtaining redirect: %v", err) | 		logger.Errorf("Error obtaining redirect: %v", err) | ||||||
| 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | ||||||
|  | @ -707,7 +668,7 @@ func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	appRedirect, err := p.getAppRedirect(req) | 	appRedirect, err := p.appDirector.GetRedirect(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Errorf("Error obtaining application redirect: %v", err) | 		logger.Errorf("Error obtaining application redirect: %v", err) | ||||||
| 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | 		p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) | ||||||
|  | @ -790,7 +751,7 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { | ||||||
| 	csrf.SetSessionNonce(session) | 	csrf.SetSessionNonce(session) | ||||||
| 	p.provider.ValidateSession(req.Context(), session) | 	p.provider.ValidateSession(req.Context(), session) | ||||||
| 
 | 
 | ||||||
| 	if !p.IsValidRedirect(appRedirect) { | 	if !p.redirectValidator.IsValidRedirect(appRedirect) { | ||||||
| 		appRedirect = "/" | 		appRedirect = "/" | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -947,158 +908,6 @@ func (p *OAuthProxy) getOAuthRedirectURI(req *http.Request) string { | ||||||
| 	return rd.String() | 	return rd.String() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getAppRedirect determines the full URL or URI path to redirect clients to
 |  | ||||||
| // once authenticated with the OAuthProxy
 |  | ||||||
| // Strategy priority (first legal result is used):
 |  | ||||||
| // - `rd` querysting parameter
 |  | ||||||
| // - `X-Auth-Request-Redirect` header
 |  | ||||||
| // - `X-Forwarded-(Proto|Host|Uri)` headers (when ReverseProxy mode is enabled)
 |  | ||||||
| // - `X-Forwarded-(Proto|Host)` if `Uri` has the ProxyPath (i.e. /oauth2/*)
 |  | ||||||
| // - `X-Forwarded-Uri` direct URI path (when ReverseProxy mode is enabled)
 |  | ||||||
| // - `req.URL.RequestURI` if not under the ProxyPath (i.e. /oauth2/*)
 |  | ||||||
| // - `/`
 |  | ||||||
| func (p *OAuthProxy) getAppRedirect(req *http.Request) (string, error) { |  | ||||||
| 	err := req.ParseForm() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return "", err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// These redirect getter functions are strategies ordered by priority
 |  | ||||||
| 	// for figuring out the redirect URL.
 |  | ||||||
| 	type redirectGetter func(req *http.Request) string |  | ||||||
| 	for _, rdGetter := range []redirectGetter{ |  | ||||||
| 		p.getRdQuerystringRedirect, |  | ||||||
| 		p.getXAuthRequestRedirect, |  | ||||||
| 		p.getXForwardedHeadersRedirect, |  | ||||||
| 		p.getURIRedirect, |  | ||||||
| 	} { |  | ||||||
| 		redirect := rdGetter(req) |  | ||||||
| 		// Call `p.IsValidRedirect` again here a final time to be safe
 |  | ||||||
| 		if redirect != "" && p.IsValidRedirect(redirect) { |  | ||||||
| 			return redirect, nil |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return "/", nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func isForwardedRequest(req *http.Request) bool { |  | ||||||
| 	return requestutil.IsProxied(req) && |  | ||||||
| 		req.Host != requestutil.GetRequestHost(req) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (p *OAuthProxy) hasProxyPrefix(path string) bool { |  | ||||||
| 	return strings.HasPrefix(path, fmt.Sprintf("%s/", p.ProxyPrefix)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (p *OAuthProxy) validateRedirect(redirect string, errorFormat string) string { |  | ||||||
| 	if p.IsValidRedirect(redirect) { |  | ||||||
| 		return redirect |  | ||||||
| 	} |  | ||||||
| 	if redirect != "" { |  | ||||||
| 		logger.Errorf(errorFormat, redirect) |  | ||||||
| 	} |  | ||||||
| 	return "" |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getRdQuerystringRedirect handles this getAppRedirect strategy:
 |  | ||||||
| // - `rd` querysting parameter
 |  | ||||||
| func (p *OAuthProxy) getRdQuerystringRedirect(req *http.Request) string { |  | ||||||
| 	return p.validateRedirect( |  | ||||||
| 		req.Form.Get("rd"), |  | ||||||
| 		"Invalid redirect provided in rd querystring parameter: %s", |  | ||||||
| 	) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getXAuthRequestRedirect handles this getAppRedirect strategy:
 |  | ||||||
| // - `X-Auth-Request-Redirect` Header
 |  | ||||||
| func (p *OAuthProxy) getXAuthRequestRedirect(req *http.Request) string { |  | ||||||
| 	return p.validateRedirect( |  | ||||||
| 		req.Header.Get("X-Auth-Request-Redirect"), |  | ||||||
| 		"Invalid redirect provided in X-Auth-Request-Redirect header: %s", |  | ||||||
| 	) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getXForwardedHeadersRedirect handles these getAppRedirect strategies:
 |  | ||||||
| // - `X-Forwarded-(Proto|Host|Uri)` headers (when ReverseProxy mode is enabled)
 |  | ||||||
| // - `X-Forwarded-(Proto|Host)` if `Uri` has the ProxyPath (i.e. /oauth2/*)
 |  | ||||||
| func (p *OAuthProxy) getXForwardedHeadersRedirect(req *http.Request) string { |  | ||||||
| 	if !isForwardedRequest(req) { |  | ||||||
| 		return "" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	uri := requestutil.GetRequestURI(req) |  | ||||||
| 	if p.hasProxyPrefix(uri) { |  | ||||||
| 		uri = "/" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	redirect := fmt.Sprintf( |  | ||||||
| 		"%s://%s%s", |  | ||||||
| 		requestutil.GetRequestProto(req), |  | ||||||
| 		requestutil.GetRequestHost(req), |  | ||||||
| 		uri, |  | ||||||
| 	) |  | ||||||
| 
 |  | ||||||
| 	return p.validateRedirect(redirect, |  | ||||||
| 		"Invalid redirect generated from X-Forwarded-* headers: %s") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getURIRedirect handles these getAppRedirect strategies:
 |  | ||||||
| // - `X-Forwarded-Uri` direct URI path (when ReverseProxy mode is enabled)
 |  | ||||||
| // - `req.URL.RequestURI` if not under the ProxyPath (i.e. /oauth2/*)
 |  | ||||||
| // - `/`
 |  | ||||||
| func (p *OAuthProxy) getURIRedirect(req *http.Request) string { |  | ||||||
| 	redirect := p.validateRedirect( |  | ||||||
| 		requestutil.GetRequestURI(req), |  | ||||||
| 		"Invalid redirect generated from X-Forwarded-Uri header: %s", |  | ||||||
| 	) |  | ||||||
| 	if redirect == "" { |  | ||||||
| 		redirect = req.URL.RequestURI() |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if p.hasProxyPrefix(redirect) { |  | ||||||
| 		return "/" |  | ||||||
| 	} |  | ||||||
| 	return redirect |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // splitHostPort separates host and port. If the port is not valid, it returns
 |  | ||||||
| // the entire input as host, and it doesn't check the validity of the host.
 |  | ||||||
| // Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
 |  | ||||||
| // *** taken from net/url, modified validOptionalPort() to accept ":*"
 |  | ||||||
| func splitHostPort(hostport string) (host, port string) { |  | ||||||
| 	host = hostport |  | ||||||
| 
 |  | ||||||
| 	colon := strings.LastIndexByte(host, ':') |  | ||||||
| 	if colon != -1 && validOptionalPort(host[colon:]) { |  | ||||||
| 		host, port = host[:colon], host[colon+1:] |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { |  | ||||||
| 		host = host[1 : len(host)-1] |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // validOptionalPort reports whether port is either an empty string
 |  | ||||||
| // or matches /^:\d*$/
 |  | ||||||
| // *** taken from net/url, modified to accept ":*"
 |  | ||||||
| func validOptionalPort(port string) bool { |  | ||||||
| 	if port == "" || port == ":*" { |  | ||||||
| 		return true |  | ||||||
| 	} |  | ||||||
| 	if port[0] != ':' { |  | ||||||
| 		return false |  | ||||||
| 	} |  | ||||||
| 	for _, b := range port[1:] { |  | ||||||
| 		if b < '0' || b > '9' { |  | ||||||
| 			return false |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return true |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getAuthenticatedSession checks whether a user is authenticated and returns a session object and nil error if so
 | // getAuthenticatedSession checks whether a user is authenticated and returns a session object and nil error if so
 | ||||||
| // Returns:
 | // Returns:
 | ||||||
| // - `nil, ErrNeedsLogin` if user needs to login.
 | // - `nil, ErrNeedsLogin` if user needs to login.
 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| package main | package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"bufio" |  | ||||||
| 	"context" | 	"context" | ||||||
| 	"crypto" | 	"crypto" | ||||||
| 	"encoding/base64" | 	"encoding/base64" | ||||||
|  | @ -11,7 +10,6 @@ import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"os" |  | ||||||
| 	"regexp" | 	"regexp" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -19,7 +17,6 @@ import ( | ||||||
| 
 | 
 | ||||||
| 	"github.com/coreos/go-oidc" | 	"github.com/coreos/go-oidc" | ||||||
| 	"github.com/mbland/hmacauth" | 	"github.com/mbland/hmacauth" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" |  | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies" | ||||||
|  | @ -28,10 +25,7 @@ import ( | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/upstream" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/upstream" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/providers" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/providers" | ||||||
| 	. "github.com/onsi/ginkgo" |  | ||||||
| 	. "github.com/onsi/gomega" |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| 	"github.com/stretchr/testify/require" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -63,304 +57,6 @@ func TestRobotsTxt(t *testing.T) { | ||||||
| 	assert.Equal(t, "User-agent: *\nDisallow: /\n", rw.Body.String()) | 	assert.Equal(t, "User-agent: *\nDisallow: /\n", rw.Body.String()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestIsValidRedirect(t *testing.T) { |  | ||||||
| 	opts := baseTestOptions() |  | ||||||
| 	// Should match domains that are exactly foo.bar and any subdomain of bar.foo
 |  | ||||||
| 	opts.WhitelistDomains = []string{ |  | ||||||
| 		"foo.bar", |  | ||||||
| 		".bar.foo", |  | ||||||
| 		"port.bar:8080", |  | ||||||
| 		".sub.port.bar:8080", |  | ||||||
| 		"anyport.bar:*", |  | ||||||
| 		".sub.anyport.bar:*", |  | ||||||
| 	} |  | ||||||
| 	err := validation.Validate(opts) |  | ||||||
| 	assert.NoError(t, err) |  | ||||||
| 
 |  | ||||||
| 	proxy, err := NewOAuthProxy(opts, func(string) bool { return true }) |  | ||||||
| 	if err != nil { |  | ||||||
| 		t.Fatal(err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	testCases := []struct { |  | ||||||
| 		Desc, Redirect string |  | ||||||
| 		ExpectedResult bool |  | ||||||
| 	}{ |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "noRD", |  | ||||||
| 			Redirect:       "", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "singleSlash", |  | ||||||
| 			Redirect:       "/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "doubleSlash", |  | ||||||
| 			Redirect:       "//redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validHTTP", |  | ||||||
| 			Redirect:       "http://foo.bar/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validHTTPS", |  | ||||||
| 			Redirect:       "https://foo.bar/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTPSubdomain", |  | ||||||
| 			Redirect:       "http://baz.foo.bar/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTPSSubdomain", |  | ||||||
| 			Redirect:       "https://baz.foo.bar/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validHTTPSubdomain", |  | ||||||
| 			Redirect:       "http://baz.bar.foo/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validHTTPSSubdomain", |  | ||||||
| 			Redirect:       "https://baz.bar.foo/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validHTTPDomain", |  | ||||||
| 			Redirect:       "http://bar.foo/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTP1", |  | ||||||
| 			Redirect:       "http://foo.bar.evil.corp/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTPS1", |  | ||||||
| 			Redirect:       "https://foo.bar.evil.corp/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTP2", |  | ||||||
| 			Redirect:       "http://evil.corp/redirect?rd=foo.bar", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidHTTPS2", |  | ||||||
| 			Redirect:       "https://evil.corp/redirect?rd=foo.bar", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidPort", |  | ||||||
| 			Redirect:       "https://evil.corp:3838/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidEmptyPort", |  | ||||||
| 			Redirect:       "http://foo.bar:3838/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidEmptyPortSubdomain", |  | ||||||
| 			Redirect:       "http://baz.bar.foo:3838/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validSpecificPort", |  | ||||||
| 			Redirect:       "http://port.bar:8080/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidSpecificPort", |  | ||||||
| 			Redirect:       "http://port.bar:3838/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validSpecificPortSubdomain", |  | ||||||
| 			Redirect:       "http://foo.sub.port.bar:8080/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "invalidSpecificPortSubdomain", |  | ||||||
| 			Redirect:       "http://foo.sub.port.bar:3838/redirect", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validAnyPort1", |  | ||||||
| 			Redirect:       "http://anyport.bar:8080/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validAnyPort2", |  | ||||||
| 			Redirect:       "http://anyport.bar:8081/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validAnyPortSubdomain1", |  | ||||||
| 			Redirect:       "http://a.sub.anyport.bar:8080/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "validAnyPortSubdomain2", |  | ||||||
| 			Redirect:       "http://a.sub.anyport.bar:8081/redirect", |  | ||||||
| 			ExpectedResult: true, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirect1", |  | ||||||
| 			Redirect:       "/\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectSpace1", |  | ||||||
| 			Redirect:       "/ /evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectSpace2", |  | ||||||
| 			Redirect:       "/ \\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectTab1", |  | ||||||
| 			Redirect:       "/\t/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectTab2", |  | ||||||
| 			Redirect:       "/\t\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectVerticalTab1", |  | ||||||
| 			Redirect:       "/\v/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectVerticalTab2", |  | ||||||
| 			Redirect:       "/\v\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectNewLine1", |  | ||||||
| 			Redirect:       "/\n/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectNewLine2", |  | ||||||
| 			Redirect:       "/\n\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectCarriageReturn1", |  | ||||||
| 			Redirect:       "/\r/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectCarriageReturn2", |  | ||||||
| 			Redirect:       "/\r\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectTripleTab", |  | ||||||
| 			Redirect:       "/\t\t/\t/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectTripleTab2", |  | ||||||
| 			Redirect:       "/\t\t\\\t/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectQuadTab1", |  | ||||||
| 			Redirect:       "/\t\t/\t\t\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectQuadTab2", |  | ||||||
| 			Redirect:       "/\t\t\\\t\t/evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectPeriod1", |  | ||||||
| 			Redirect:       "/./\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectPeriod2", |  | ||||||
| 			Redirect:       "/./../../\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectDoubleTab", |  | ||||||
| 			Redirect:       "/\t/\t\\evil.com", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			Desc:           "openRedirectPartialSubdomain", |  | ||||||
| 			Redirect:       "http://evilbar.foo", |  | ||||||
| 			ExpectedResult: false, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, tc := range testCases { |  | ||||||
| 		t.Run(tc.Desc, func(t *testing.T) { |  | ||||||
| 			result := proxy.IsValidRedirect(tc.Redirect) |  | ||||||
| 
 |  | ||||||
| 			if result != tc.ExpectedResult { |  | ||||||
| 				t.Errorf("expected %t got %t", tc.ExpectedResult, result) |  | ||||||
| 			} |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| var _ = Describe("OpenRedirect Tests", func() { |  | ||||||
| 	var proxy *OAuthProxy |  | ||||||
| 
 |  | ||||||
| 	BeforeEach(func() { |  | ||||||
| 		opts := baseTestOptions() |  | ||||||
| 		// Should match domains that are exactly foo.bar and any subdomain of bar.foo
 |  | ||||||
| 		opts.WhitelistDomains = []string{ |  | ||||||
| 			"foo.bar", |  | ||||||
| 			".bar.foo", |  | ||||||
| 			"port.bar:8080", |  | ||||||
| 			".sub.port.bar:8080", |  | ||||||
| 			"anyport.bar:*", |  | ||||||
| 			".sub.anyport.bar:*", |  | ||||||
| 			"www.whitelisteddomain.tld", |  | ||||||
| 		} |  | ||||||
| 		Expect(validation.Validate(opts)).To(Succeed()) |  | ||||||
| 
 |  | ||||||
| 		var err error |  | ||||||
| 		proxy, err = NewOAuthProxy(opts, func(string) bool { return true }) |  | ||||||
| 		Expect(err).ToNot(HaveOccurred()) |  | ||||||
| 	}) |  | ||||||
| 
 |  | ||||||
| 	file, err := os.Open("./testdata/openredirects.txt") |  | ||||||
| 	Expect(err).ToNot(HaveOccurred()) |  | ||||||
| 	defer func() { |  | ||||||
| 		Expect(file.Close()).To(Succeed()) |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	scanner := bufio.NewScanner(file) |  | ||||||
| 	for scanner.Scan() { |  | ||||||
| 		rd := scanner.Text() |  | ||||||
| 		It(rd, func() { |  | ||||||
| 			rdUnescaped, err := url.QueryUnescape(rd) |  | ||||||
| 			Expect(err).ToNot(HaveOccurred()) |  | ||||||
| 
 |  | ||||||
| 			Expect(proxy.IsValidRedirect(rdUnescaped)).To(BeFalse(), "Expected redirect not to be valid") |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	Expect(scanner.Err()).ToNot(HaveOccurred()) |  | ||||||
| }) |  | ||||||
| 
 |  | ||||||
| type TestProvider struct { | type TestProvider struct { | ||||||
| 	*providers.ProviderData | 	*providers.ProviderData | ||||||
| 	EmailAddress   string | 	EmailAddress   string | ||||||
|  | @ -1770,165 +1466,6 @@ func TestRequestSignature(t *testing.T) { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func Test_getAppRedirect(t *testing.T) { |  | ||||||
| 	opts := baseTestOptions() |  | ||||||
| 	opts.WhitelistDomains = append(opts.WhitelistDomains, ".example.com", ".example.com:8443") |  | ||||||
| 	err := validation.Validate(opts) |  | ||||||
| 	assert.NoError(t, err) |  | ||||||
| 	require.NotEmpty(t, opts.ProxyPrefix) |  | ||||||
| 	proxy, err := NewOAuthProxy(opts, func(s string) bool { return false }) |  | ||||||
| 	if err != nil { |  | ||||||
| 		t.Fatal(err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	tests := []struct { |  | ||||||
| 		name             string |  | ||||||
| 		url              string |  | ||||||
| 		headers          map[string]string |  | ||||||
| 		reverseProxy     bool |  | ||||||
| 		expectedRedirect string |  | ||||||
| 	}{ |  | ||||||
| 		{ |  | ||||||
| 			name:             "request outside of ProxyPrefix redirects to original URL", |  | ||||||
| 			url:              "/foo/bar", |  | ||||||
| 			headers:          nil, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "/foo/bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name:             "request with query preserves query", |  | ||||||
| 			url:              "/foo?bar", |  | ||||||
| 			headers:          nil, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "/foo?bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name:             "request under ProxyPrefix redirects to root", |  | ||||||
| 			url:              proxy.ProxyPrefix + "/foo/bar", |  | ||||||
| 			headers:          nil, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "/", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request outside of ProxyPrefix redirects to proxied URL", |  | ||||||
| 			url:  "https://oauth.example.com/foo/bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "a-service.example.com", |  | ||||||
| 				"X-Forwarded-Uri":   "/foo/bar", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo/bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "non-proxied request with spoofed proxy headers wouldn't redirect", |  | ||||||
| 			url:  "https://oauth.example.com/foo?bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "a-service.example.com", |  | ||||||
| 				"X-Forwarded-Uri":   "/foo/bar", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "/foo?bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request under ProxyPrefix redirects to root", |  | ||||||
| 			url:  "https://oauth.example.com" + proxy.ProxyPrefix + "/foo/bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "a-service.example.com", |  | ||||||
| 				"X-Forwarded-Uri":   proxy.ProxyPrefix + "/foo/bar", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request with port under ProxyPrefix redirects to root", |  | ||||||
| 			url:  "https://oauth.example.com" + proxy.ProxyPrefix + "/foo/bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "a-service.example.com:8443", |  | ||||||
| 				"X-Forwarded-Uri":   proxy.ProxyPrefix + "/foo/bar", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com:8443/", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request with missing uri header would still redirect to desired redirect", |  | ||||||
| 			url:  "https://oauth.example.com/foo?bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "a-service.example.com", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo?bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name:             "request with headers proxy not being set (and reverse proxy enabled) would still redirect to desired redirect", |  | ||||||
| 			url:              "https://oauth.example.com/foo?bar", |  | ||||||
| 			headers:          nil, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "/foo?bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request with X-Auth-Request-Redirect being set outside of ProxyPrefix redirects to proxied URL", |  | ||||||
| 			url:  "https://oauth.example.com/foo/bar", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Auth-Request-Redirect": "https://a-service.example.com/foo/bar", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo/bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name:             "proxied request with rd query string redirects to proxied URL", |  | ||||||
| 			url:              "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fbar", |  | ||||||
| 			headers:          nil, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo/bar", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request with rd query string and all headers set (and reverse proxy not enabled) redirects to proxied URL on rd query string", |  | ||||||
| 			url:  "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fjazz", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Auth-Request-Redirect": "https://a-service.example.com/foo/baz", |  | ||||||
| 				"X-Forwarded-Proto":       "http", |  | ||||||
| 				"X-Forwarded-Host":        "another-service.example.com", |  | ||||||
| 				"X-Forwarded-Uri":         "/seasons/greetings", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     false, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo/jazz", |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name: "proxied request with rd query string and some headers set redirects to proxied URL on rd query string", |  | ||||||
| 			url:  "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fbaz", |  | ||||||
| 			headers: map[string]string{ |  | ||||||
| 				"X-Forwarded-Proto": "https", |  | ||||||
| 				"X-Forwarded-Host":  "another-service.example.com", |  | ||||||
| 				"X-Forwarded-Uri":   "/seasons/greetings", |  | ||||||
| 			}, |  | ||||||
| 			reverseProxy:     true, |  | ||||||
| 			expectedRedirect: "https://a-service.example.com/foo/baz", |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 	for _, tt := range tests { |  | ||||||
| 		t.Run(tt.name, func(t *testing.T) { |  | ||||||
| 			req, _ := http.NewRequest("GET", tt.url, nil) |  | ||||||
| 			for header, value := range tt.headers { |  | ||||||
| 				if value != "" { |  | ||||||
| 					req.Header.Add(header, value) |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 			req = middleware.AddRequestScope(req, &middleware.RequestScope{ |  | ||||||
| 				ReverseProxy: tt.reverseProxy, |  | ||||||
| 			}) |  | ||||||
| 			redirect, err := proxy.getAppRedirect(req) |  | ||||||
| 
 |  | ||||||
| 			assert.NoError(t, err) |  | ||||||
| 			assert.Equal(t, tt.expectedRedirect, redirect) |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type ajaxRequestTest struct { | type ajaxRequestTest struct { | ||||||
| 	opts  *options.Options | 	opts  *options.Options | ||||||
| 	proxy *OAuthProxy | 	proxy *OAuthProxy | ||||||
|  |  | ||||||
|  | @ -0,0 +1,96 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // AppDirector is responsible for determining where OAuth2 Proxy should redirect
 | ||||||
|  | // a users request to after the user has authenticated with the identity provider.
 | ||||||
|  | type AppDirector interface { | ||||||
|  | 	GetRedirect(req *http.Request) (string, error) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AppDirectorOpts are the requirements for constructing a new AppDirector.
 | ||||||
|  | type AppDirectorOpts struct { | ||||||
|  | 	ProxyPrefix string | ||||||
|  | 	Validator   Validator | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // NewAppDirector constructs a new AppDirector for getting the application
 | ||||||
|  | // redirect URL.
 | ||||||
|  | func NewAppDirector(opts AppDirectorOpts) AppDirector { | ||||||
|  | 	prefix := opts.ProxyPrefix | ||||||
|  | 	if !strings.HasSuffix(prefix, "/") { | ||||||
|  | 		prefix = fmt.Sprintf("%s/", prefix) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &appDirector{ | ||||||
|  | 		proxyPrefix: prefix, | ||||||
|  | 		validator:   opts.Validator, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // appDirector implements the AppDirector interface.
 | ||||||
|  | type appDirector struct { | ||||||
|  | 	proxyPrefix string | ||||||
|  | 	validator   Validator | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetRedirect determines the full URL or URI path to redirect clients to once
 | ||||||
|  | // authenticated with the OAuthProxy.
 | ||||||
|  | // Strategy priority (first legal result is used):
 | ||||||
|  | // - `rd` querysting parameter
 | ||||||
|  | // - `X-Auth-Request-Redirect` header
 | ||||||
|  | // - `X-Forwarded-(Proto|Host|Uri)` headers (when ReverseProxy mode is enabled)
 | ||||||
|  | // - `X-Forwarded-(Proto|Host)` if `Uri` has the ProxyPath (i.e. /oauth2/*)
 | ||||||
|  | // - `X-Forwarded-Uri` direct URI path (when ReverseProxy mode is enabled)
 | ||||||
|  | // - `req.URL.RequestURI` if not under the ProxyPath (i.e. /oauth2/*)
 | ||||||
|  | // - `/`
 | ||||||
|  | func (a *appDirector) GetRedirect(req *http.Request) (string, error) { | ||||||
|  | 	err := req.ParseForm() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// These redirect getter functions are strategies ordered by priority
 | ||||||
|  | 	// for figuring out the redirect URL.
 | ||||||
|  | 	for _, rdGetter := range []redirectGetter{ | ||||||
|  | 		a.getRdQuerystringRedirect, | ||||||
|  | 		a.getXAuthRequestRedirect, | ||||||
|  | 		a.getXForwardedHeadersRedirect, | ||||||
|  | 		a.getURIRedirect, | ||||||
|  | 	} { | ||||||
|  | 		redirect := rdGetter(req) | ||||||
|  | 		// Call `p.IsValidRedirect` again here a final time to be safe
 | ||||||
|  | 		if redirect != "" && a.validator.IsValidRedirect(redirect) { | ||||||
|  | 			return redirect, nil | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return "/", nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // validateRedirect checks that the redirect is valid.
 | ||||||
|  | // When an invalid, non-empty redirect is found, an error will be logged using
 | ||||||
|  | // the provided format.
 | ||||||
|  | func (a *appDirector) validateRedirect(redirect string, errorFormat string) string { | ||||||
|  | 	if a.validator.IsValidRedirect(redirect) { | ||||||
|  | 		return redirect | ||||||
|  | 	} | ||||||
|  | 	if redirect != "" { | ||||||
|  | 		logger.Errorf(errorFormat, redirect) | ||||||
|  | 	} | ||||||
|  | 	return "" | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // hasProxyPrefix determines whether the obtained path would be a request to
 | ||||||
|  | // one of OAuth2 Proxy's own endpoints, eg. th callback URL.
 | ||||||
|  | // Redirects to these endpoints should not be allowed as they will create
 | ||||||
|  | // redirection loops.
 | ||||||
|  | func (a *appDirector) hasProxyPrefix(path string) bool { | ||||||
|  | 	return strings.HasPrefix(path, a.proxyPrefix) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,177 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | 
 | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" | ||||||
|  | 	. "github.com/onsi/ginkgo" | ||||||
|  | 	. "github.com/onsi/ginkgo/extensions/table" | ||||||
|  | 	. "github.com/onsi/gomega" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | const testProxyPrefix = "/oauth2" | ||||||
|  | 
 | ||||||
|  | var _ = Describe("Director Suite", func() { | ||||||
|  | 	type getRedirectTableInput struct { | ||||||
|  | 		requestURL       string | ||||||
|  | 		headers          map[string]string | ||||||
|  | 		reverseProxy     bool | ||||||
|  | 		validator        Validator | ||||||
|  | 		expectedRedirect string | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	DescribeTable("GetRedirect", | ||||||
|  | 		func(in getRedirectTableInput) { | ||||||
|  | 			appDirector := NewAppDirector(AppDirectorOpts{ | ||||||
|  | 				ProxyPrefix: testProxyPrefix, | ||||||
|  | 				Validator:   in.validator, | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			req, _ := http.NewRequest("GET", in.requestURL, nil) | ||||||
|  | 			for header, value := range in.headers { | ||||||
|  | 				if value != "" { | ||||||
|  | 					req.Header.Add(header, value) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			req = middleware.AddRequestScope(req, &middleware.RequestScope{ | ||||||
|  | 				ReverseProxy: in.reverseProxy, | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			redirect, err := appDirector.GetRedirect(req) | ||||||
|  | 			Expect(err).ToNot(HaveOccurred()) | ||||||
|  | 			Expect(redirect).To(Equal(in.expectedRedirect)) | ||||||
|  | 		}, | ||||||
|  | 		Entry("Request outside of the proxy prefix, redirects to original request", getRedirectTableInput{ | ||||||
|  | 			requestURL:       "/foo/bar", | ||||||
|  | 			headers:          nil, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "/foo/bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Request with query, preserves the query", getRedirectTableInput{ | ||||||
|  | 			requestURL:       "/foo?bar", | ||||||
|  | 			headers:          nil, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "/foo?bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Request under the proxy prefix, redirects to root", getRedirectTableInput{ | ||||||
|  | 			requestURL:       testProxyPrefix + "/foo/bar", | ||||||
|  | 			headers:          nil, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "/", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with headers, outside of ProxyPrefix, redirects to proxied URL", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo/bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":   "/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Non-proxied request with spoofed headers, wouldn't redirect", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo?bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":   "/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "/foo?bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with headers, under ProxyPrefix, redirects to  root", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com" + testProxyPrefix + "/foo/bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":   testProxyPrefix + "/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with port, under ProxyPrefix, redirects to  root", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com" + testProxyPrefix + "/foo/bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com:8443", | ||||||
|  | 				"X-Forwarded-Uri":   testProxyPrefix + "/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com:8443/", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with headers, missing URI header, redirects to the desired redirect URL", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo?bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo?bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request without headers, with reverse proxy enabled, redirects to the desired URL", getRedirectTableInput{ | ||||||
|  | 			requestURL:       "https://oauth.example.com/foo?bar", | ||||||
|  | 			headers:          nil, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "/foo?bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with X-Auth-Request-Redirect, outside of ProxyPrefix, redirects to proxied URL", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo/bar", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Auth-Request-Redirect": "https://a-service.example.com/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with RD parameter, outside of ProxyPrefix, redirects to proxied URL", getRedirectTableInput{ | ||||||
|  | 			requestURL:       "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fbar", | ||||||
|  | 			headers:          nil, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/bar", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with RD parameter and all headers set, reverse proxy disabled, redirects to proxied URL based on the RD parameter", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fjazz", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Auth-Request-Redirect": "https://a-service.example.com/foo/baz", | ||||||
|  | 				"X-Forwarded-Proto":       "http", | ||||||
|  | 				"X-Forwarded-Host":        "another-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":         "/seasons/greetings", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     false, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/jazz", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with RD parameter and some headers set, reverse proxy enabled, redirects to proxied URL based on the RD parameter", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo/bar?rd=https%3A%2F%2Fa%2Dservice%2Eexample%2Ecom%2Ffoo%2Fjazz", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "http", | ||||||
|  | 				"X-Forwarded-Host":  "another-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":   "/seasons/greetings", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(true), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/jazz", | ||||||
|  | 		}), | ||||||
|  | 		Entry("Proxied request with invalid RD parameter and some headers set, reverse proxy enabled, redirects to proxied URL based on the headers", getRedirectTableInput{ | ||||||
|  | 			requestURL: "https://oauth.example.com/foo/bar?rd=http%3A%2F%2Fanother%2Dservice%2Eexample%2Ecom%2Ffoo%2Fjazz", | ||||||
|  | 			headers: map[string]string{ | ||||||
|  | 				"X-Forwarded-Proto": "https", | ||||||
|  | 				"X-Forwarded-Host":  "a-service.example.com", | ||||||
|  | 				"X-Forwarded-Uri":   "/foo/bar", | ||||||
|  | 			}, | ||||||
|  | 			reverseProxy:     true, | ||||||
|  | 			validator:        testValidator(false, "https://a-service.example.com/foo/bar"), | ||||||
|  | 			expectedRedirect: "https://a-service.example.com/foo/bar", | ||||||
|  | 		}), | ||||||
|  | 	) | ||||||
|  | }) | ||||||
|  | @ -0,0 +1,73 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 
 | ||||||
|  | 	requestutil "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // redirectGetter represents a method to allow the proxy to determine a redirect
 | ||||||
|  | // based on the original request.
 | ||||||
|  | type redirectGetter func(req *http.Request) string | ||||||
|  | 
 | ||||||
|  | // getRdQuerystringRedirect handles this getAppRedirect strategy:
 | ||||||
|  | // - `rd` querysting parameter
 | ||||||
|  | func (a *appDirector) getRdQuerystringRedirect(req *http.Request) string { | ||||||
|  | 	return a.validateRedirect( | ||||||
|  | 		req.Form.Get("rd"), | ||||||
|  | 		"Invalid redirect provided in rd querystring parameter: %s", | ||||||
|  | 	) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getXAuthRequestRedirect handles this getAppRedirect strategy:
 | ||||||
|  | // - `X-Auth-Request-Redirect` Header
 | ||||||
|  | func (a *appDirector) getXAuthRequestRedirect(req *http.Request) string { | ||||||
|  | 	return a.validateRedirect( | ||||||
|  | 		req.Header.Get("X-Auth-Request-Redirect"), | ||||||
|  | 		"Invalid redirect provided in X-Auth-Request-Redirect header: %s", | ||||||
|  | 	) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getXForwardedHeadersRedirect handles these getAppRedirect strategies:
 | ||||||
|  | // - `X-Forwarded-(Proto|Host|Uri)` headers (when ReverseProxy mode is enabled)
 | ||||||
|  | // - `X-Forwarded-(Proto|Host)` if `Uri` has the ProxyPath (i.e. /oauth2/*)
 | ||||||
|  | func (a *appDirector) getXForwardedHeadersRedirect(req *http.Request) string { | ||||||
|  | 	if !requestutil.IsForwardedRequest(req) { | ||||||
|  | 		return "" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	uri := requestutil.GetRequestURI(req) | ||||||
|  | 	if a.hasProxyPrefix(uri) { | ||||||
|  | 		uri = "/" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	redirect := fmt.Sprintf( | ||||||
|  | 		"%s://%s%s", | ||||||
|  | 		requestutil.GetRequestProto(req), | ||||||
|  | 		requestutil.GetRequestHost(req), | ||||||
|  | 		uri, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	return a.validateRedirect(redirect, | ||||||
|  | 		"Invalid redirect generated from X-Forwarded-* headers: %s") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getURIRedirect handles these getAppRedirect strategies:
 | ||||||
|  | // - `X-Forwarded-Uri` direct URI path (when ReverseProxy mode is enabled)
 | ||||||
|  | // - `req.URL.RequestURI` if not under the ProxyPath (i.e. /oauth2/*)
 | ||||||
|  | // - `/`
 | ||||||
|  | func (a *appDirector) getURIRedirect(req *http.Request) string { | ||||||
|  | 	redirect := a.validateRedirect( | ||||||
|  | 		requestutil.GetRequestURI(req), | ||||||
|  | 		"Invalid redirect generated from X-Forwarded-Uri header: %s", | ||||||
|  | 	) | ||||||
|  | 	if redirect == "" { | ||||||
|  | 		redirect = req.URL.RequestURI() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if a.hasProxyPrefix(redirect) { | ||||||
|  | 		return "/" | ||||||
|  | 	} | ||||||
|  | 	return redirect | ||||||
|  | } | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" | ||||||
|  | 	. "github.com/onsi/ginkgo" | ||||||
|  | 	. "github.com/onsi/gomega" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestOptionsSuite(t *testing.T) { | ||||||
|  | 	logger.SetOutput(GinkgoWriter) | ||||||
|  | 	logger.SetErrOutput(GinkgoWriter) | ||||||
|  | 
 | ||||||
|  | 	RegisterFailHandler(Fail) | ||||||
|  | 	RunSpecs(t, "Redirect Suite") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // testValidator creates a mock validator that will always return the given result.
 | ||||||
|  | func testValidator(result bool, allowedRedirects ...string) Validator { | ||||||
|  | 	return &mockValidator{result: result, allowedRedirects: allowedRedirects} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // mockValidator implements the Validator interface for use in testing.
 | ||||||
|  | type mockValidator struct { | ||||||
|  | 	result           bool | ||||||
|  | 	allowedRedirects []string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // IsValidRedirect implements the Validator interface.
 | ||||||
|  | func (m *mockValidator) IsValidRedirect(redirect string) bool { | ||||||
|  | 	for _, allowed := range m.allowedRedirects { | ||||||
|  | 		if redirect == allowed { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return m.result | ||||||
|  | } | ||||||
|  | @ -0,0 +1,120 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"net/url" | ||||||
|  | 	"regexp" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	// Used to check final redirects are not susceptible to open redirects.
 | ||||||
|  | 	// Matches //, /\ and both of these with whitespace in between (eg / / or / \).
 | ||||||
|  | 	invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`) | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Validator is an interface to allow validation of application
 | ||||||
|  | // redirect URLs.
 | ||||||
|  | // As these values are determined from the request, they must go
 | ||||||
|  | // through thorough checks to ensure the safety of the end user.
 | ||||||
|  | type Validator interface { | ||||||
|  | 	IsValidRedirect(redirect string) bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // NewValidator constructs a new redirect validator.
 | ||||||
|  | func NewValidator(allowedDomains []string) Validator { | ||||||
|  | 	return &validator{ | ||||||
|  | 		allowedDomains: allowedDomains, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // validator implements the Validator interface to allow validation
 | ||||||
|  | // of redirect URLs.
 | ||||||
|  | type validator struct { | ||||||
|  | 	allowedDomains []string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // IsValidRedirect checks whether the redirect URL is safe and allowed.
 | ||||||
|  | func (v *validator) IsValidRedirect(redirect string) bool { | ||||||
|  | 	switch { | ||||||
|  | 	case redirect == "": | ||||||
|  | 		// The user didn't specify a redirect.
 | ||||||
|  | 		// In this case, we expect the proxt to fallback to `/`
 | ||||||
|  | 		return false | ||||||
|  | 	case strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") && !invalidRedirectRegex.MatchString(redirect): | ||||||
|  | 		return true | ||||||
|  | 	case strings.HasPrefix(redirect, "http://") || strings.HasPrefix(redirect, "https://"): | ||||||
|  | 		redirectURL, err := url.Parse(redirect) | ||||||
|  | 		if err != nil { | ||||||
|  | 			logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect) | ||||||
|  | 			return false | ||||||
|  | 		} | ||||||
|  | 		redirectHostname := redirectURL.Hostname() | ||||||
|  | 
 | ||||||
|  | 		for _, allowedDomain := range v.allowedDomains { | ||||||
|  | 			allowedHost, allowedPort := splitHostPort(allowedDomain) | ||||||
|  | 			if allowedHost == "" { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if redirectHostname == strings.TrimPrefix(allowedHost, ".") || | ||||||
|  | 				(strings.HasPrefix(allowedHost, ".") && | ||||||
|  | 					strings.HasSuffix(redirectHostname, allowedHost)) { | ||||||
|  | 				// the domain names match, now validate the ports
 | ||||||
|  | 				// if the whitelisted domain's port is '*', allow all ports
 | ||||||
|  | 				// if the whitelisted domain contains a specific port, only allow that port
 | ||||||
|  | 				// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
 | ||||||
|  | 				redirectPort := redirectURL.Port() | ||||||
|  | 				if allowedPort == "*" || | ||||||
|  | 					allowedPort == redirectPort || | ||||||
|  | 					(allowedPort == "" && redirectPort == "") { | ||||||
|  | 					return true | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect) | ||||||
|  | 		return false | ||||||
|  | 	default: | ||||||
|  | 		logger.Printf("Rejecting invalid redirect %q: not an absolute or relative URL", redirect) | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // splitHostPort separates host and port. If the port is not valid, it returns
 | ||||||
|  | // the entire input as host, and it doesn't check the validity of the host.
 | ||||||
|  | // Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
 | ||||||
|  | // *** taken from net/url, modified validOptionalPort() to accept ":*"
 | ||||||
|  | func splitHostPort(hostport string) (host, port string) { | ||||||
|  | 	host = hostport | ||||||
|  | 
 | ||||||
|  | 	colon := strings.LastIndexByte(host, ':') | ||||||
|  | 	if colon != -1 && validOptionalPort(host[colon:]) { | ||||||
|  | 		host, port = host[:colon], host[colon+1:] | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | ||||||
|  | 		host = host[1 : len(host)-1] | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // validOptionalPort reports whether port is either an empty string
 | ||||||
|  | // or matches /^:\d*$/
 | ||||||
|  | // *** taken from net/url, modified to accept ":*"
 | ||||||
|  | func validOptionalPort(port string) bool { | ||||||
|  | 	if port == "" || port == ":*" { | ||||||
|  | 		return true | ||||||
|  | 	} | ||||||
|  | 	if port[0] != ':' { | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	for _, b := range port[1:] { | ||||||
|  | 		if b < '0' || b > '9' { | ||||||
|  | 			return false | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return true | ||||||
|  | } | ||||||
|  | @ -0,0 +1,143 @@ | ||||||
|  | package redirect | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"bufio" | ||||||
|  | 	"net/url" | ||||||
|  | 	"os" | ||||||
|  | 
 | ||||||
|  | 	. "github.com/onsi/ginkgo" | ||||||
|  | 	. "github.com/onsi/ginkgo/extensions/table" | ||||||
|  | 	. "github.com/onsi/gomega" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var _ = Describe("Validator suite", func() { | ||||||
|  | 	var testAllowedDomains []string | ||||||
|  | 
 | ||||||
|  | 	BeforeEach(func() { | ||||||
|  | 		testAllowedDomains = []string{ | ||||||
|  | 			"foo.bar", | ||||||
|  | 			".bar.foo", | ||||||
|  | 			"port.bar:8080", | ||||||
|  | 			".sub.port.bar:8080", | ||||||
|  | 			"anyport.bar:*", | ||||||
|  | 			".sub.anyport.bar:*", | ||||||
|  | 			"www.whitelisteddomain.tld", | ||||||
|  | 		} | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	Context("OpenRedirect List", func() { | ||||||
|  | 		file, err := os.Open("../../../testdata/openredirects.txt") | ||||||
|  | 		Expect(err).ToNot(HaveOccurred()) | ||||||
|  | 		defer func() { | ||||||
|  | 			Expect(file.Close()).To(Succeed()) | ||||||
|  | 		}() | ||||||
|  | 
 | ||||||
|  | 		scanner := bufio.NewScanner(file) | ||||||
|  | 		for scanner.Scan() { | ||||||
|  | 			rd := scanner.Text() | ||||||
|  | 			It(rd, func() { | ||||||
|  | 				rdUnescaped, err := url.QueryUnescape(rd) | ||||||
|  | 				Expect(err).ToNot(HaveOccurred()) | ||||||
|  | 
 | ||||||
|  | 				validator := NewValidator(testAllowedDomains) | ||||||
|  | 				Expect(validator.IsValidRedirect(rdUnescaped)).To(BeFalse(), "Expected redirect not to be valid") | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		Expect(scanner.Err()).ToNot(HaveOccurred()) | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	Context("Validator", func() { | ||||||
|  | 		DescribeTable("IsValidRedirect", | ||||||
|  | 			func(testRedirect string, expected bool) { | ||||||
|  | 				validator := NewValidator(testAllowedDomains) | ||||||
|  | 				Expect(validator.IsValidRedirect(testRedirect)).To(Equal(expected)) | ||||||
|  | 			}, | ||||||
|  | 			Entry("No Redirect", "", false), | ||||||
|  | 			Entry("Single Slash", "/redirect", true), | ||||||
|  | 			Entry("Double Slash", "//redirect", false), | ||||||
|  | 			Entry("Valid HTTP", "http://foo.bar/redirect", true), | ||||||
|  | 			Entry("Valid HTTPS", "https://foo.bar/redirect", true), | ||||||
|  | 			Entry("Invalid HTTP subdomain", "http://baz.foo.bar/redirect", false), | ||||||
|  | 			Entry("Invalid HTTPS subdomain", "https://baz.foo.bar/redirect", false), | ||||||
|  | 			Entry("Valid HTTP subdomain", "http://baz.bar.foo/redirect", true), | ||||||
|  | 			Entry("Valid HTTPS subdomain", "https://baz.bar.foo/redirect", true), | ||||||
|  | 			Entry("Valid HTTP Domain", "http://bar.foo/redirect", true), // Is this correct, do we want to match the root domain?
 | ||||||
|  | 			Entry("Invalid HTTP Similar Domain", "http://foo.bar.evil.corp/redirect", false), | ||||||
|  | 			Entry("Invalid HTTPS Similar Domain", "https://foo.bar.evil.corp/redirect", false), | ||||||
|  | 			Entry("Invalid HTTP RD Parameter", "http://evil.corp/redirect?rd=foo.bar", false), | ||||||
|  | 			Entry("Invalid HTTPS RD Parameter", "https://evil.corp/redirect?rd=foo.bar", false), | ||||||
|  | 			Entry("Invalid Port and Domain", "https://evil.corp:3838/redirect", false), | ||||||
|  | 			Entry("Invalid Port on Allowed Domain", "http://foo.bar:3838/redirect", false), | ||||||
|  | 			Entry("Invalid Port on Allowed Subdomain", "http://baz.bar.foo:3838/redirect", false), | ||||||
|  | 			Entry("Valid Specified Port and Domain", "http://port.bar:8080/redirect", true), | ||||||
|  | 			Entry("Invalid Specified Port and Domain", "http://port.bar:3838/redirect", false), | ||||||
|  | 			Entry("Valid Specified Port and Subdomain", "http://foo.sub.port.bar:8080/redirect", true), | ||||||
|  | 			Entry("Invalid Specified Port andSubdomain", "http://foo.subport.bar:3838/redirect", false), | ||||||
|  | 			Entry("Valid Any Port, Specified Domain", "http://anyport.bar:8080/redirect", true), | ||||||
|  | 			Entry("Valid Different Any Port, Specified Domain", "http://anyport.bar:8081/redirect", true), | ||||||
|  | 			Entry("Valid Any Port, Specified Subdomain", "http://a.sub.anyport.bar:8080/redirect", true), | ||||||
|  | 			Entry("Valid Different Any Port, Specified Subdomain", "http://a.sub.anyport.bar:8081/redirect", true), | ||||||
|  | 			Entry("Escape Double Slash", "/\\evil.com", false), | ||||||
|  | 			Entry("Space Single Slash", "/ /evil.com", false), | ||||||
|  | 			Entry("Space Double Slash", "/ \\evil.com", false), | ||||||
|  | 			Entry("Tab Single Slash", "/\t/evil.com", false), | ||||||
|  | 			Entry("Tab Double Slash", "/\t\\evil.com", false), | ||||||
|  | 			Entry("Vertical Tab Single Slash", "/\v/evil.com", false), | ||||||
|  | 			Entry("Vertiacl Tab Double Slash", "/\v\\evil.com", false), | ||||||
|  | 			Entry("New Line Single Slash", "/\n/evil.com", false), | ||||||
|  | 			Entry("New Line Double Slash", "/\n\\evil.com", false), | ||||||
|  | 			Entry("Carriage Return Single Slash", "/\r/evil.com", false), | ||||||
|  | 			Entry("Carriage Return Double Slash", "/\r\\evil.com", false), | ||||||
|  | 			Entry("Double Tab", "/\t/\t\\evil.com", false), | ||||||
|  | 			Entry("Triple Tab 1", "/\t\t/\t/evil.com", false), | ||||||
|  | 			Entry("Triple Tab 2", "/\t\t\\\t/evil.com", false), | ||||||
|  | 			Entry("Quad Tab 1", "/\t\t/\t\t\\evil.com", false), | ||||||
|  | 			Entry("Quad Tab 2", "/\t\t\\\t\t/evil.com", false), | ||||||
|  | 			Entry("Relative Path", "/./\\evil.com", false), | ||||||
|  | 			Entry("Relative Subpath", "/./../../\\evil.com", false), | ||||||
|  | 			Entry("Partial Subdomain", "evilbar.foo", false), | ||||||
|  | 		) | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	Context("SplitHostPort", func() { | ||||||
|  | 		type splitHostPortTableInput struct { | ||||||
|  | 			hostport     string | ||||||
|  | 			expectedHost string | ||||||
|  | 			expectedPort string | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		DescribeTable("Should split the host and port", | ||||||
|  | 			func(in splitHostPortTableInput) { | ||||||
|  | 				host, port := splitHostPort(in.hostport) | ||||||
|  | 				Expect(host).To(Equal(in.expectedHost)) | ||||||
|  | 				Expect(port).To(Equal(in.expectedPort)) | ||||||
|  | 			}, | ||||||
|  | 			Entry("when no port is specified", splitHostPortTableInput{ | ||||||
|  | 				hostport:     "foo.bar", | ||||||
|  | 				expectedHost: "foo.bar", | ||||||
|  | 				expectedPort: "", | ||||||
|  | 			}), | ||||||
|  | 			Entry("with a valid port specified", splitHostPortTableInput{ | ||||||
|  | 				hostport:     "foo.bar:8080", | ||||||
|  | 				expectedHost: "foo.bar", | ||||||
|  | 				expectedPort: "8080", | ||||||
|  | 			}), | ||||||
|  | 			Entry("with an invalid port specified", splitHostPortTableInput{ | ||||||
|  | 				hostport:     "foo.bar:808a", | ||||||
|  | 				expectedHost: "foo.bar:808a", | ||||||
|  | 				expectedPort: "", | ||||||
|  | 			}), | ||||||
|  | 			Entry("with a wildcard port specified", splitHostPortTableInput{ | ||||||
|  | 				hostport:     "foo.bar:*", | ||||||
|  | 				expectedHost: "foo.bar", | ||||||
|  | 				expectedPort: "*", | ||||||
|  | 			}), | ||||||
|  | 			Entry("when the host is specified with brackets", splitHostPortTableInput{ | ||||||
|  | 				hostport:     "[foo.bar]", | ||||||
|  | 				expectedHost: "foo.bar", | ||||||
|  | 				expectedPort: "", | ||||||
|  | 			}), | ||||||
|  | 		) | ||||||
|  | 	}) | ||||||
|  | }) | ||||||
|  | @ -52,3 +52,8 @@ func IsProxied(req *http.Request) bool { | ||||||
| 	} | 	} | ||||||
| 	return scope.ReverseProxy | 	return scope.ReverseProxy | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func IsForwardedRequest(req *http.Request) bool { | ||||||
|  | 	return IsProxied(req) && | ||||||
|  | 		req.Host != GetRequestHost(req) | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue