From e1764d42215fd99ee675661bbaf7b22f256b8f8e Mon Sep 17 00:00:00 2001 From: Joel Speed <joel.speed@hotmail.co.uk> Date: Sun, 30 May 2021 09:34:37 +0100 Subject: [PATCH] Create AppDirector for getting the application redirect URL --- pkg/app/redirect/director.go | 96 ++++++++++++ pkg/app/redirect/director_test.go | 177 ++++++++++++++++++++++ pkg/app/redirect/getters.go | 73 +++++++++ pkg/app/redirect/pagewriter_suite_test.go | 22 +++ pkg/requests/util/util.go | 5 + 5 files changed, 373 insertions(+) create mode 100644 pkg/app/redirect/director.go create mode 100644 pkg/app/redirect/director_test.go create mode 100644 pkg/app/redirect/getters.go diff --git a/pkg/app/redirect/director.go b/pkg/app/redirect/director.go new file mode 100644 index 00000000..0ed8e408 --- /dev/null +++ b/pkg/app/redirect/director.go @@ -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) +} diff --git a/pkg/app/redirect/director_test.go b/pkg/app/redirect/director_test.go new file mode 100644 index 00000000..55359f52 --- /dev/null +++ b/pkg/app/redirect/director_test.go @@ -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", + }), + ) +}) diff --git a/pkg/app/redirect/getters.go b/pkg/app/redirect/getters.go new file mode 100644 index 00000000..5240abeb --- /dev/null +++ b/pkg/app/redirect/getters.go @@ -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 +} diff --git a/pkg/app/redirect/pagewriter_suite_test.go b/pkg/app/redirect/pagewriter_suite_test.go index 45703ff3..71301f58 100644 --- a/pkg/app/redirect/pagewriter_suite_test.go +++ b/pkg/app/redirect/pagewriter_suite_test.go @@ -15,3 +15,25 @@ func TestOptionsSuite(t *testing.T) { 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 +} diff --git a/pkg/requests/util/util.go b/pkg/requests/util/util.go index 9805db61..44e1ab0e 100644 --- a/pkg/requests/util/util.go +++ b/pkg/requests/util/util.go @@ -52,3 +52,8 @@ func IsProxied(req *http.Request) bool { } return scope.ReverseProxy } + +func IsForwardedRequest(req *http.Request) bool { + return IsProxied(req) && + req.Host != GetRequestHost(req) +}