git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #1060 from oauth2-proxy/upstream-rewrite

Implement RewriteTarget to allow requests to be rewritten before proxying to upstream servers

commitac188b129fcb6a0c9df6adbb88b7a90660e68423 authorJoel Speed <Joel.speed@hotmail.co.uk> dateWed 23 Jun 2021, 13:36 (5 years ago) parent16a98939ce962b
10 files changed+637−178
CHANGELOG.md+1 docs/docs/configuration/alpha_config.md+2−1 go.mod+3−3 go.sum+4−2 pkg/apis/options/upstreams.go+14 pkg/upstream/proxy.go+117−12 pkg/upstream/proxy_test.go+355−160 pkg/upstream/rewrite.go+80 pkg/upstream/rewrite_test.go+60 pkg/upstream/upstream_suite_test.go+1
CHANGELOG.md+1
@@ -13,6 +13,7 @@
1313
1414## Changes since v7.1.3
1515
16+- [#1060](https://github.com/oauth2-proxy/oauth2-proxy/pull/1060) Implement RewriteTarget to allow requests to be rewritten before proxying to upstream servers (@JoelSpeed)
1617- [#1086](https://github.com/oauth2-proxy/oauth2-proxy/pull/1086) Refresh sessions before token expiration if configured (@NickMeves)
1718- [#1226](https://github.com/oauth2-proxy/oauth2-proxy/pull/1226) Move app redirection logic to its own package (@JoelSpeed)
1819- [#1128](https://github.com/oauth2-proxy/oauth2-proxy/pull/1128) Use gorilla mux for OAuth Proxy routing (@JoelSpeed)
docs/docs/configuration/alpha_config.md+2−1
@@ -371,7 +371,8 @@ Requests will be proxied to this upstream if the path matches the request path.
371371| Field | Type | Description |
372372| ----- | ---- | ----------- |
373373| `id` | _string_ | ID should be a unique identifier for the upstream.<br/>This value is required for all upstreams. |
374−| `path` | _string_ | Path is used to map requests to the upstream server.<br/>The closest match will take precedence and all Paths must be unique. |
374+| `path` | _string_ | Path is used to map requests to the upstream server.<br/>The closest match will take precedence and all Paths must be unique.<br/>Path can also take a pattern when used with RewriteTarget.<br/>Path segments can be captured and matched using regular experessions.<br/>Eg:<br/>- `^/foo$`: Match only the explicit path `/foo`<br/>- `^/bar/$`: Match any path prefixed with `/bar/`<br/>- `^/baz/(.*)$`: Match any path prefixed with `/baz` and capture the remaining path for use with RewriteTarget |
375+| `rewriteTarget` | _string_ | RewriteTarget allows users to rewrite the request path before it is sent to<br/>the upstream server.<br/>Use the Path to capture segments for reuse within the rewrite target.<br/>Eg: With a Path of `^/baz/(.*)`, a RewriteTarget of `/foo/$1` would rewrite<br/>the request `/baz/abc/123` to `/foo/abc/123` before proxying to the<br/>upstream server. |
375376| `uri` | _string_ | The URI of the upstream server. This may be an HTTP(S) server of a File<br/>based URL. It may include a path, in which case all requests will be served<br/>under that path.<br/>Eg:<br/>- http://localhost:8080<br/>- https://service.localhost<br/>- https://service.localhost/path<br/>- file://host/path<br/>If the URI's path is "/base" and the incoming request was for "/dir",<br/>the upstream request will be for "/base/dir". |
376377| `insecureSkipTLSVerify` | _bool_ | InsecureSkipTLSVerify will skip TLS verification of upstream HTTPS hosts.<br/>This option is insecure and will allow potential Man-In-The-Middle attacks<br/>betweem OAuth2 Proxy and the usptream server.<br/>Defaults to false. |
377378| `static` | _bool_ | Static will make all requests to this upstream have a static response.<br/>The response will have a body of "Authenticated" and a response code<br/>matching StaticCode.<br/>If StaticCode is not set, the response will return a 200 response. |
go.mod+3−3
@@ -16,7 +16,7 @@ require (
1616 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32
1717 github.com/go-redis/redis/v8 v8.2.3
1818 github.com/google/uuid v1.2.0
19− github.com/gorilla/mux v1.8.0 // indirect
19+ github.com/gorilla/mux v1.8.0
2020 github.com/justinas/alice v1.2.0
2121 github.com/mbland/hmacauth v0.0.0-20170912233209-44256dfd4bfa
2222 github.com/mitchellh/mapstructure v1.1.2
@@ -31,8 +31,8 @@ require (
3131 github.com/stretchr/testify v1.6.1
3232 github.com/vmihailenco/msgpack/v4 v4.3.11
3333 github.com/yhat/wsutil v0.0.0-20170731153501-1d66fa95c997
34− golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
35− golang.org/x/net v0.0.0-20200707034311-ab3426394381
34+ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
35+ golang.org/x/net v0.0.0-20200822124328-c89045814202
3636 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
3737 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
3838 google.golang.org/api v0.20.0
go.sum+4−2
@@ -460,8 +460,9 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
460460golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
461461golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
462462golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
463−golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
464463golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
464+golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
465+golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
465466golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
466467golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
467468golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
@@ -503,8 +504,9 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
503504golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
504505golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
505506golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
506−golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
507507golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
508+golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
509+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
508510golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
509511golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
510512golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
pkg/apis/options/upstreams.go+14
@@ -19,8 +19,22 @@ type Upstream struct {
1919
2020 // Path is used to map requests to the upstream server.
2121 // The closest match will take precedence and all Paths must be unique.
22+ // Path can also take a pattern when used with RewriteTarget.
23+ // Path segments can be captured and matched using regular experessions.
24+ // Eg:
25+ // - `^/foo$`: Match only the explicit path `/foo`
26+ // - `^/bar/$`: Match any path prefixed with `/bar/`
27+ // - `^/baz/(.*)$`: Match any path prefixed with `/baz` and capture the remaining path for use with RewriteTarget
2228 Path string `json:"path,omitempty"`
2329
30+ // RewriteTarget allows users to rewrite the request path before it is sent to
31+ // the upstream server.
32+ // Use the Path to capture segments for reuse within the rewrite target.
33+ // Eg: With a Path of `^/baz/(.*)`, a RewriteTarget of `/foo/$1` would rewrite
34+ // the request `/baz/abc/123` to `/foo/abc/123` before proxying to the
35+ // upstream server.
36+ RewriteTarget string `json:"rewriteTarget,omitempty"`
37+
2438 // The URI of the upstream server. This may be an HTTP(S) server of a File
2539 // based URL. It may include a path, in which case all requests will be served
2640 // under that path.
pkg/upstream/proxy.go+117−12
@@ -1,10 +1,16 @@
11package upstream
22
33import (
4+ "context"
45 "fmt"
56 "net/http"
67 "net/url"
8+ "regexp"
9+ "sort"
10+ "strings"
711
12+ "github.com/gorilla/mux"
13+ "github.com/justinas/alice"
814 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
915 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter"
1016 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
@@ -18,12 +24,14 @@ type ProxyErrorHandler func(http.ResponseWriter, *http.Request, error)
1824// multiple upstreams.
1925func NewProxy(upstreams options.Upstreams, sigData *options.SignatureData, writer pagewriter.Writer) (http.Handler, error) {
2026 m := &multiUpstreamProxy{
21− serveMux: http.NewServeMux(),
27+ serveMux: mux.NewRouter(),
2228 }
2329
24− for _, upstream := range upstreams {
30+ for _, upstream := range sortByPathLongest(upstreams) {
2531 if upstream.Static {
26− m.registerStaticResponseHandler(upstream)
32+ if err := m.registerStaticResponseHandler(upstream, writer); err != nil {
33+ return nil, fmt.Errorf("could not register static upstream %q: %v", upstream.ID, err)
34+ }
2735 continue
2836 }
2937
@@ -33,20 +41,26 @@ func NewProxy(upstreams options.Upstreams, sigData *options.SignatureData, write
3341 }
3442 switch u.Scheme {
3543 case fileScheme:
36− m.registerFileServer(upstream, u)
44+ if err := m.registerFileServer(upstream, u, writer); err != nil {
45+ return nil, fmt.Errorf("could not register file upstream %q: %v", upstream.ID, err)
46+ }
3747 case httpScheme, httpsScheme:
38− m.registerHTTPUpstreamProxy(upstream, u, sigData, writer)
48+ if err := m.registerHTTPUpstreamProxy(upstream, u, sigData, writer); err != nil {
49+ return nil, fmt.Errorf("could not register HTTP upstream %q: %v", upstream.ID, err)
50+ }
3951 default:
4052 return nil, fmt.Errorf("unknown scheme for upstream %q: %q", upstream.ID, u.Scheme)
4153 }
4254 }
55+
56+ registerTrailingSlashHandler(m.serveMux)
4357 return m, nil
4458}
4559
4660// multiUpstreamProxy will serve requests directed to multiple upstream servers
4761// registered in the serverMux.
4862type multiUpstreamProxy struct {
49− serveMux *http.ServeMux
63+ serveMux *mux.Router
5064}
5165
5266// ServerHTTP handles HTTP requests.
@@ -55,19 +69,110 @@ func (m *multiUpstreamProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request
5569}
5670
5771// registerStaticResponseHandler registers a static response handler with at the given path.
58−func (m *multiUpstreamProxy) registerStaticResponseHandler(upstream options.Upstream) {
72+func (m *multiUpstreamProxy) registerStaticResponseHandler(upstream options.Upstream, writer pagewriter.Writer) error {
5973 logger.Printf("mapping path %q => static response %d", upstream.Path, derefStaticCode(upstream.StaticCode))
60− m.serveMux.Handle(upstream.Path, newStaticResponseHandler(upstream.ID, upstream.StaticCode))
74+ return m.registerHandler(upstream, newStaticResponseHandler(upstream.ID, upstream.StaticCode), writer)
6175}
6276
6377// registerFileServer registers a new fileServer based on the configuration given.
64−func (m *multiUpstreamProxy) registerFileServer(upstream options.Upstream, u *url.URL) {
78+func (m *multiUpstreamProxy) registerFileServer(upstream options.Upstream, u *url.URL, writer pagewriter.Writer) error {
6579 logger.Printf("mapping path %q => file system %q", upstream.Path, u.Path)
66− m.serveMux.Handle(upstream.Path, newFileServer(upstream.ID, upstream.Path, u.Path))
80+ return m.registerHandler(upstream, newFileServer(upstream.ID, upstream.Path, u.Path), writer)
6781}
6882
6983// registerHTTPUpstreamProxy registers a new httpUpstreamProxy based on the configuration given.
70−func (m *multiUpstreamProxy) registerHTTPUpstreamProxy(upstream options.Upstream, u *url.URL, sigData *options.SignatureData, writer pagewriter.Writer) {
84+func (m *multiUpstreamProxy) registerHTTPUpstreamProxy(upstream options.Upstream, u *url.URL, sigData *options.SignatureData, writer pagewriter.Writer) error {
7185 logger.Printf("mapping path %q => upstream %q", upstream.Path, upstream.URI)
72− m.serveMux.Handle(upstream.Path, newHTTPUpstreamProxy(upstream, u, sigData, writer.ProxyErrorHandler))
86+ return m.registerHandler(upstream, newHTTPUpstreamProxy(upstream, u, sigData, writer.ProxyErrorHandler), writer)
87+}
88+
89+// registerHandler ensures the given handler is regiestered with the serveMux.
90+func (m *multiUpstreamProxy) registerHandler(upstream options.Upstream, handler http.Handler, writer pagewriter.Writer) error {
91+ if upstream.RewriteTarget == "" {
92+ m.registerSimpleHandler(upstream.Path, handler)
93+ return nil
94+ }
95+
96+ return m.registerRewriteHandler(upstream, handler, writer)
97+}
98+
99+// registerSimpleHandler maintains the behaviour of the go standard serveMux
100+// by ensuring any path with a trailing `/` matches all paths under that prefix.
101+func (m *multiUpstreamProxy) registerSimpleHandler(path string, handler http.Handler) {
102+ if strings.HasSuffix(path, "/") {
103+ m.serveMux.PathPrefix(path).Handler(handler)
104+ } else {
105+ m.serveMux.Path(path).Handler(handler)
106+ }
107+}
108+
109+// registerRewriteHandler ensures the handler is registered for all paths
110+// which match the regex defined in the Path.
111+// Requests to the handler will have the request path rewritten before the
112+// request is made to the next handler.
113+func (m *multiUpstreamProxy) registerRewriteHandler(upstream options.Upstream, handler http.Handler, writer pagewriter.Writer) error {
114+ rewriteRegExp, err := regexp.Compile(upstream.Path)
115+ if err != nil {
116+ return fmt.Errorf("invalid path %q for upstream: %v", upstream.Path, err)
117+ }
118+
119+ rewrite := newRewritePath(rewriteRegExp, upstream.RewriteTarget, writer)
120+ h := alice.New(rewrite).Then(handler)
121+ m.serveMux.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
122+ return rewriteRegExp.MatchString(req.URL.Path)
123+ }).Handler(h)
124+
125+ return nil
126+}
127+
128+// registerTrailingSlashHandler creates a new matcher that will check if the
129+// requested path would match if it had a trailing slash appended.
130+// If the path matches with a trailing slash, we send back a redirect.
131+// This allows us to be consistent with the built in go servemux implementation.
132+func registerTrailingSlashHandler(serveMux *mux.Router) {
133+ serveMux.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
134+ if strings.HasSuffix(req.URL.Path, "/") {
135+ return false
136+ }
137+
138+ // Use a separate RouteMatch so that we can redirect to the path + /.
139+ // If we pass through the match then the matched backed will be served
140+ // instead of the redirect handler.
141+ m := &mux.RouteMatch{}
142+ slashReq := req.Clone(context.Background())
143+ slashReq.URL.Path += "/"
144+ return serveMux.Match(slashReq, m)
145+ }).Handler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
146+ http.Redirect(rw, req, req.URL.String()+"/", http.StatusMovedPermanently)
147+ }))
148+}
149+
150+// sortByPathLongest ensures that the upstreams are sorted by longest path.
151+// If rewrites are involved, a rewrite takes precedence over a non-rewrite.
152+// When two upstreams define rewrites, whichever has the longest path will take
153+// precedence (note this is the input to the rewrite logic).
154+// This does not account for when a rewrite would actually make the path shorter.
155+// This should maintain the sorting behaviour of the standard go serve mux.
156+func sortByPathLongest(in options.Upstreams) options.Upstreams {
157+ sort.Slice(in, func(i, j int) bool {
158+ iRW := in[i].RewriteTarget
159+ jRW := in[j].RewriteTarget
160+
161+ switch {
162+ case iRW != "" && jRW != "":
163+ // If both have a rewrite target, whichever has the longest pattern
164+ // should go first
165+ return len(in[i].Path) > len(in[j].Path)
166+ case iRW != "" && jRW == "":
167+ // Only one has rewrite, it goes first
168+ return true
169+ case iRW == "" && jRW != "":
170+ // Only one has rewrite, it goes first
171+ return false
172+ default:
173+ // Default to longest Path wins
174+ return len(in[i].Path) > len(in[j].Path)
175+ }
176+ })
177+ return in
73178}
pkg/upstream/proxy_test.go+355−160
@@ -18,174 +18,369 @@ import (
1818var _ = Describe("Proxy Suite", func() {
1919 var upstreamServer http.Handler
2020
21− BeforeEach(func() {
22− sigData := &options.SignatureData{Hash: crypto.SHA256, Key: "secret"}
21+ Context("multiUpstreamProxy", func() {
22+ BeforeEach(func() {
23+ sigData := &options.SignatureData{Hash: crypto.SHA256, Key: "secret"}
2324
24− writer := &pagewriter.WriterFuncs{
25− ProxyErrorFunc: func(rw http.ResponseWriter, _ *http.Request, _ error) {
26− rw.WriteHeader(502)
27− rw.Write([]byte("Proxy Error"))
28− },
29− }
25+ writer := &pagewriter.WriterFuncs{
26+ ProxyErrorFunc: func(rw http.ResponseWriter, _ *http.Request, _ error) {
27+ rw.WriteHeader(502)
28+ rw.Write([]byte("Proxy Error"))
29+ },
30+ }
3031
31− ok := http.StatusOK
32+ ok := http.StatusOK
33+ accepted := http.StatusAccepted
3234
33− upstreams := options.Upstreams{
34− {
35− ID: "http-backend",
36− Path: "/http/",
37− URI: serverAddr,
38− },
39− {
40− ID: "file-backend",
41− Path: "/files/",
42− URI: fmt.Sprintf("file:///%s", filesDir),
43− },
44− {
45− ID: "static-backend",
46− Path: "/static/",
47− Static: true,
48− StaticCode: &ok,
49− },
50− {
51− ID: "bad-http-backend",
52− Path: "/bad-http/",
53− URI: "http://::1",
54− },
55− {
56− ID: "single-path-backend",
57− Path: "/single-path",
58− Static: true,
59− StaticCode: &ok,
60− },
35+ upstreams := options.Upstreams{
36+ {
37+ ID: "http-backend",
38+ Path: "/http/",
39+ URI: serverAddr,
40+ },
41+ {
42+ ID: "file-backend",
43+ Path: "/files/",
44+ URI: fmt.Sprintf("file:///%s", filesDir),
45+ },
46+ {
47+ ID: "static-backend",
48+ Path: "/static/",
49+ Static: true,
50+ StaticCode: &ok,
51+ },
52+ {
53+ ID: "static-backend-no-trailing-slash",
54+ Path: "/static",
55+ Static: true,
56+ StaticCode: &accepted,
57+ },
58+ {
59+ ID: "static-backend-long",
60+ Path: "/static/long",
61+ Static: true,
62+ StaticCode: &accepted,
63+ },
64+ {
65+ ID: "bad-http-backend",
66+ Path: "/bad-http/",
67+ URI: "http://::1",
68+ },
69+ {
70+ ID: "single-path-backend",
71+ Path: "/single-path",
72+ Static: true,
73+ StaticCode: &ok,
74+ },
75+ {
76+ ID: "backend-with-rewrite-prefix",
77+ Path: "^/rewrite-prefix/(.*)",
78+ RewriteTarget: "/different/backend/path/$1",
79+ URI: serverAddr,
80+ },
81+ {
82+ ID: "double-match-plain",
83+ Path: "/double-match/",
84+ URI: serverAddr,
85+ },
86+ {
87+ ID: "double-match-rewrite",
88+ Path: "^/double-match/(.*)",
89+ RewriteTarget: "/double-match/rewrite/$1",
90+ URI: serverAddr,
91+ },
92+ }
93+
94+ var err error
95+ upstreamServer, err = NewProxy(upstreams, sigData, writer)
96+ Expect(err).ToNot(HaveOccurred())
97+ })
98+
99+ type proxyTableInput struct {
100+ target string
101+ response testHTTPResponse
102+ upstream string
61103 }
62104
63− var err error
64− upstreamServer, err = NewProxy(upstreams, sigData, writer)
65− Expect(err).ToNot(HaveOccurred())
66− })
105+ DescribeTable("Proxy ServeHTTP",
106+ func(in *proxyTableInput) {
107+ req := middlewareapi.AddRequestScope(
108+ httptest.NewRequest("", in.target, nil),
109+ &middlewareapi.RequestScope{},
110+ )
111+ rw := httptest.NewRecorder()
112+ // Don't mock the remote Address
113+ req.RemoteAddr = ""
67114
68− type proxyTableInput struct {
69− target string
70− response testHTTPResponse
71− upstream string
72− }
73−
74− DescribeTable("Proxy ServeHTTP",
75− func(in *proxyTableInput) {
76− req := middlewareapi.AddRequestScope(
77− httptest.NewRequest("", in.target, nil),
78− &middlewareapi.RequestScope{},
79− )
80− rw := httptest.NewRecorder()
81− // Don't mock the remote Address
82− req.RemoteAddr = ""
83−
84− upstreamServer.ServeHTTP(rw, req)
85−
86− scope := middlewareapi.GetRequestScope(req)
87− Expect(scope.Upstream).To(Equal(in.upstream))
88−
89− Expect(rw.Code).To(Equal(in.response.code))
90−
91− // Delete extra headers that aren't relevant to tests
92− testSanitizeResponseHeader(rw.Header())
93− Expect(rw.Header()).To(Equal(in.response.header))
94−
95− body := rw.Body.Bytes()
96− // If the raw body is set, check that, else check the Request object
97− if in.response.raw != "" {
98− Expect(string(body)).To(Equal(in.response.raw))
99− return
100− }
115+ upstreamServer.ServeHTTP(rw, req)
116+
117+ scope := middlewareapi.GetRequestScope(req)
118+ Expect(scope.Upstream).To(Equal(in.upstream))
119+
120+ Expect(rw.Code).To(Equal(in.response.code))
121+
122+ // Delete extra headers that aren't relevant to tests
123+ testSanitizeResponseHeader(rw.Header())
124+ Expect(rw.Header()).To(Equal(in.response.header))
125+
126+ body := rw.Body.Bytes()
127+ // If the raw body is set, check that, else check the Request object
128+ if in.response.raw != "" {
129+ Expect(string(body)).To(Equal(in.response.raw))
130+ return
131+ }
101132
102− // Compare the reflected request to the upstream
103− request := testHTTPRequest{}
104− Expect(json.Unmarshal(body, &request)).To(Succeed())
105− testSanitizeRequestHeader(request.Header)
106− Expect(request).To(Equal(in.response.request))
107− },
108− Entry("with a request to the HTTP service", &proxyTableInput{
109− target: "http://example.localhost/http/1234",
110− response: testHTTPResponse{
111− code: 200,
112− header: map[string][]string{
113− contentType: {applicationJSON},
114− },
115− request: testHTTPRequest{
116− Method: "GET",
117− URL: "http://example.localhost/http/1234",
118− Header: map[string][]string{
119− "Gap-Auth": {""},
120− "Gap-Signature": {"sha256 ofB1u6+FhEUbFLc3/uGbJVkl7GaN4egFqVvyO3+2I1w="},
133+ // Compare the reflected request to the upstream
134+ request := testHTTPRequest{}
135+ Expect(json.Unmarshal(body, &request)).To(Succeed())
136+ testSanitizeRequestHeader(request.Header)
137+ Expect(request).To(Equal(in.response.request))
138+ },
139+ Entry("with a request to the HTTP service", &proxyTableInput{
140+ target: "http://example.localhost/http/1234",
141+ response: testHTTPResponse{
142+ code: 200,
143+ header: map[string][]string{
144+ contentType: {applicationJSON},
145+ },
146+ request: testHTTPRequest{
147+ Method: "GET",
148+ URL: "http://example.localhost/http/1234",
149+ Header: map[string][]string{
150+ "Gap-Auth": {""},
151+ "Gap-Signature": {"sha256 ofB1u6+FhEUbFLc3/uGbJVkl7GaN4egFqVvyO3+2I1w="},
152+ },
153+ Body: []byte{},
154+ Host: "example.localhost",
155+ RequestURI: "http://example.localhost/http/1234",
121156 },
122− Body: []byte{},
123− Host: "example.localhost",
124− RequestURI: "http://example.localhost/http/1234",
125157 },
158+ upstream: "http-backend",
159+ }),
160+ Entry("with a request to the File backend", &proxyTableInput{
161+ target: "http://example.localhost/files/foo",
162+ response: testHTTPResponse{
163+ code: 200,
164+ header: map[string][]string{
165+ contentType: {textPlainUTF8},
166+ },
167+ raw: "foo",
168+ },
169+ upstream: "file-backend",
170+ }),
171+ Entry("with a request to the Static backend", &proxyTableInput{
172+ target: "http://example.localhost/static/bar",
173+ response: testHTTPResponse{
174+ code: 200,
175+ header: map[string][]string{},
176+ raw: "Authenticated",
177+ },
178+ upstream: "static-backend",
179+ }),
180+ Entry("with a request to the bad HTTP backend", &proxyTableInput{
181+ target: "http://example.localhost/bad-http/bad",
182+ response: testHTTPResponse{
183+ code: 502,
184+ header: map[string][]string{},
185+ // This tests the error handler
186+ raw: "Proxy Error",
187+ },
188+ upstream: "bad-http-backend",
189+ }),
190+ Entry("with a request to the to an unregistered path", &proxyTableInput{
191+ target: "http://example.localhost/unregistered",
192+ response: testHTTPResponse{
193+ code: 404,
194+ header: map[string][]string{
195+ "X-Content-Type-Options": {"nosniff"},
196+ contentType: {textPlainUTF8},
197+ },
198+ raw: "404 page not found\n",
199+ },
200+ }),
201+ Entry("with a request to the to backend registered to a single path", &proxyTableInput{
202+ target: "http://example.localhost/single-path",
203+ response: testHTTPResponse{
204+ code: 200,
205+ header: map[string][]string{},
206+ raw: "Authenticated",
207+ },
208+ upstream: "single-path-backend",
209+ }),
210+ Entry("with a request to the to a subpath of a backend registered to a single path", &proxyTableInput{
211+ target: "http://example.localhost/single-path/unregistered",
212+ response: testHTTPResponse{
213+ code: 404,
214+ header: map[string][]string{
215+ "X-Content-Type-Options": {"nosniff"},
216+ contentType: {textPlainUTF8},
217+ },
218+ raw: "404 page not found\n",
219+ },
220+ }),
221+ Entry("with a request to the rewrite prefix server", &proxyTableInput{
222+ target: "http://example.localhost/rewrite-prefix/1234",
223+ response: testHTTPResponse{
224+ code: 200,
225+ header: map[string][]string{
226+ contentType: {applicationJSON},
227+ },
228+ request: testHTTPRequest{
229+ Method: "GET",
230+ URL: "http://example.localhost/different/backend/path/1234",
231+ Header: map[string][]string{
232+ "Gap-Auth": {""},
233+ "Gap-Signature": {"sha256 jeAeM7wHSj2ab/l9YPvtTJ9l/8q1tpY2V/iwXF48bgw="},
234+ },
235+ Body: []byte{},
236+ Host: "example.localhost",
237+ RequestURI: "http://example.localhost/different/backend/path/1234",
238+ },
239+ },
240+ upstream: "backend-with-rewrite-prefix",
241+ }),
242+ Entry("with a request to a subpath of the rewrite prefix server", &proxyTableInput{
243+ target: "http://example.localhost/rewrite-prefix/1234/abc",
244+ response: testHTTPResponse{
245+ code: 200,
246+ header: map[string][]string{
247+ contentType: {applicationJSON},
248+ },
249+ request: testHTTPRequest{
250+ Method: "GET",
251+ URL: "http://example.localhost/different/backend/path/1234/abc",
252+ Header: map[string][]string{
253+ "Gap-Auth": {""},
254+ "Gap-Signature": {"sha256 rAkAc9gp7EndoOppJuvbuPnYuBcqrTkBnQx6iPS8xTA="},
255+ },
256+ Body: []byte{},
257+ Host: "example.localhost",
258+ RequestURI: "http://example.localhost/different/backend/path/1234/abc",
259+ },
260+ },
261+ upstream: "backend-with-rewrite-prefix",
262+ }),
263+ Entry("with a request to a path, missing the trailing slash", &proxyTableInput{
264+ target: "http://example.localhost/http",
265+ response: testHTTPResponse{
266+ code: 301,
267+ header: map[string][]string{
268+ contentType: {textHTMLUTF8},
269+ "Location": {"http://example.localhost/http/"},
270+ },
271+ raw: "<a href=\"http://example.localhost/http/\">Moved Permanently</a>.\n\n",
272+ },
273+ }),
274+ Entry("with a request to a path, missing the trailing slash, but registered separately", &proxyTableInput{
275+ target: "http://example.localhost/static",
276+ response: testHTTPResponse{
277+ code: 202,
278+ header: map[string][]string{},
279+ raw: "Authenticated",
280+ },
281+ upstream: "static-backend-no-trailing-slash",
282+ }),
283+ Entry("should match longest path first", &proxyTableInput{
284+ target: "http://example.localhost/static/long",
285+ response: testHTTPResponse{
286+ code: 202,
287+ header: map[string][]string{},
288+ raw: "Authenticated",
289+ },
290+ upstream: "static-backend-long",
291+ }),
292+ Entry("should match rewrite path first", &proxyTableInput{
293+ target: "http://example.localhost/double-match/foo",
294+ response: testHTTPResponse{
295+ code: 200,
296+ header: map[string][]string{
297+ contentType: {applicationJSON},
298+ },
299+ request: testHTTPRequest{
300+ Method: "GET",
301+ URL: "http://example.localhost/double-match/rewrite/foo",
302+ Header: map[string][]string{
303+ "Gap-Auth": {""},
304+ "Gap-Signature": {"sha256 eYyUNdsrTmnvFpavpP8AdHGUGzqJ39QEjqn0/3fQPHA="},
305+ },
306+ Body: []byte{},
307+ Host: "example.localhost",
308+ RequestURI: "http://example.localhost/double-match/rewrite/foo",
309+ },
310+ },
311+ upstream: "double-match-rewrite",
312+ }),
313+ )
314+ })
315+
316+ Context("sortByPathLongest", func() {
317+ type sortByPathLongestTableInput struct {
318+ input options.Upstreams
319+ expectedOutput options.Upstreams
320+ }
321+
322+ var httpPath = options.Upstream{
323+ Path: "/http/",
324+ }
325+
326+ var httpSubPath = options.Upstream{
327+ Path: "/http/subpath/",
328+ }
329+
330+ var longerPath = options.Upstream{
331+ Path: "/longer-than-http",
332+ }
333+
334+ var shortPathWithRewrite = options.Upstream{
335+ Path: "^/h/(.*)",
336+ RewriteTarget: "/$1",
337+ }
338+
339+ var shortSubPathWithRewrite = options.Upstream{
340+ Path: "^/h/bar/(.*)",
341+ RewriteTarget: "/$1",
342+ }
343+
344+ DescribeTable("short sort into the correct order",
345+ func(in sortByPathLongestTableInput) {
346+ Expect(sortByPathLongest(in.input)).To(Equal(in.expectedOutput))
126347 },
127− upstream: "http-backend",
128− }),
129− Entry("with a request to the File backend", &proxyTableInput{
130− target: "http://example.localhost/files/foo",
131− response: testHTTPResponse{
132− code: 200,
133− header: map[string][]string{
134− contentType: {textPlainUTF8},
135− },
136− raw: "foo",
137− },
138− upstream: "file-backend",
139− }),
140− Entry("with a request to the Static backend", &proxyTableInput{
141− target: "http://example.localhost/static/bar",
142− response: testHTTPResponse{
143− code: 200,
144− header: map[string][]string{},
145− raw: "Authenticated",
146− },
147− upstream: "static-backend",
148− }),
149− Entry("with a request to the bad HTTP backend", &proxyTableInput{
150− target: "http://example.localhost/bad-http/bad",
151− response: testHTTPResponse{
152− code: 502,
153− header: map[string][]string{},
154− // This tests the error handler
155− raw: "Proxy Error",
156− },
157− upstream: "bad-http-backend",
158− }),
159− Entry("with a request to the to an unregistered path", &proxyTableInput{
160− target: "http://example.localhost/unregistered",
161− response: testHTTPResponse{
162− code: 404,
163− header: map[string][]string{
164− "X-Content-Type-Options": {"nosniff"},
165− contentType: {textPlainUTF8},
166− },
167− raw: "404 page not found\n",
168− },
169− }),
170− Entry("with a request to the to backend registered to a single path", &proxyTableInput{
171− target: "http://example.localhost/single-path",
172− response: testHTTPResponse{
173− code: 200,
174− header: map[string][]string{},
175− raw: "Authenticated",
176− },
177− upstream: "single-path-backend",
178− }),
179− Entry("with a request to the to a subpath of a backend registered to a single path", &proxyTableInput{
180− target: "http://example.localhost/single-path/unregistered",
181− response: testHTTPResponse{
182− code: 404,
183− header: map[string][]string{
184− "X-Content-Type-Options": {"nosniff"},
185− contentType: {textPlainUTF8},
186− },
187− raw: "404 page not found\n",
188− },
189− }),
190− )
348+ Entry("with a mix of paths registered", sortByPathLongestTableInput{
349+ input: options.Upstreams{httpPath, httpSubPath, shortSubPathWithRewrite, longerPath, shortPathWithRewrite},
350+ expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite, longerPath, httpSubPath, httpPath},
351+ }),
352+ Entry("when a subpath is registered (in order)", sortByPathLongestTableInput{
353+ input: options.Upstreams{httpSubPath, httpPath},
354+ expectedOutput: options.Upstreams{httpSubPath, httpPath},
355+ }),
356+ Entry("when a subpath is registered (out of order)", sortByPathLongestTableInput{
357+ input: options.Upstreams{httpPath, httpSubPath},
358+ expectedOutput: options.Upstreams{httpSubPath, httpPath},
359+ }),
360+ Entry("when longer paths are registered (in order)", sortByPathLongestTableInput{
361+ input: options.Upstreams{longerPath, httpPath},
362+ expectedOutput: options.Upstreams{longerPath, httpPath},
363+ }),
364+ Entry("when longer paths are registered (out of order)", sortByPathLongestTableInput{
365+ input: options.Upstreams{httpPath, longerPath},
366+ expectedOutput: options.Upstreams{longerPath, httpPath},
367+ }),
368+ Entry("when a rewrite target is registered (in order)", sortByPathLongestTableInput{
369+ input: options.Upstreams{shortPathWithRewrite, longerPath},
370+ expectedOutput: options.Upstreams{shortPathWithRewrite, longerPath},
371+ }),
372+ Entry("when a rewrite target is registered (out of order)", sortByPathLongestTableInput{
373+ input: options.Upstreams{longerPath, shortPathWithRewrite},
374+ expectedOutput: options.Upstreams{shortPathWithRewrite, longerPath},
375+ }),
376+ Entry("with multiple rewrite targets registered (in order)", sortByPathLongestTableInput{
377+ input: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
378+ expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
379+ }),
380+ Entry("with multiple rewrite targets registered (out of order)", sortByPathLongestTableInput{
381+ input: options.Upstreams{shortPathWithRewrite, shortSubPathWithRewrite},
382+ expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
383+ }),
384+ )
385+ })
191386})
pkg/upstream/rewrite.gonew file+80
@@ -0,0 +1,80 @@
1+package upstream
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "net/url"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/justinas/alice"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter"
13+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
14+)
15+
16+// newRewritePath creates a new middleware that will rewrite the request URI
17+// path before handing the request to the next server.
18+func newRewritePath(rewriteRegExp *regexp.Regexp, rewriteTarget string, writer pagewriter.Writer) alice.Constructor {
19+ return func(next http.Handler) http.Handler {
20+ return rewritePath(rewriteRegExp, rewriteTarget, writer, next)
21+ }
22+}
23+
24+// rewritePath uses the regexp to rewrite the request URI based on the provided
25+// rewriteTarget.
26+func rewritePath(rewriteRegExp *regexp.Regexp, rewriteTarget string, writer pagewriter.Writer, next http.Handler) http.Handler {
27+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
28+ reqURL, err := url.ParseRequestURI(req.RequestURI)
29+ if err != nil {
30+ logger.Errorf("could not parse request URI: %v", err)
31+ writer.WriteErrorPage(rw, pagewriter.ErrorPageOpts{
32+ Status: http.StatusInternalServerError,
33+ RequestID: middleware.GetRequestScope(req).RequestID,
34+ AppError: fmt.Sprintf("Could not parse request URI: %v", err),
35+ })
36+ return
37+ }
38+
39+ // Use the regex to rewrite the request path before proxying to the upstream.
40+ newURI := rewriteRegExp.ReplaceAllString(reqURL.Path, rewriteTarget)
41+ reqURL.Path, reqURL.RawQuery, err = splitPathAndQuery(reqURL.Query(), newURI)
42+ if err != nil {
43+ logger.Errorf("could not parse rewrite URI: %v", err)
44+ writer.WriteErrorPage(rw, pagewriter.ErrorPageOpts{
45+ Status: http.StatusInternalServerError,
46+ RequestID: middleware.GetRequestScope(req).RequestID,
47+ AppError: fmt.Sprintf("Could not parse rewrite URI: %v", err),
48+ })
49+ return
50+ }
51+
52+ req.RequestURI = reqURL.String()
53+ next.ServeHTTP(rw, req)
54+ })
55+}
56+
57+// splitPathAndQuery splits the rewritten path into the URL Path and the URL
58+// raw query. Any rewritten query values are appended to the original query
59+// values.
60+// This relies on the underlying URL library to encode the query string.
61+// For duplicate values it appends each as a separate value, e.g. ?foo=bar&foo=baz.
62+func splitPathAndQuery(originalQuery url.Values, raw string) (string, string, error) {
63+ s := strings.SplitN(raw, "?", 2)
64+ if len(s) == 1 {
65+ return s[0], originalQuery.Encode(), nil
66+ }
67+
68+ queryValues, err := url.ParseQuery(s[1])
69+ if err != nil {
70+ return "", "", nil
71+ }
72+
73+ for key, values := range queryValues {
74+ for _, value := range values {
75+ originalQuery.Add(key, value)
76+ }
77+ }
78+
79+ return s[0], originalQuery.Encode(), nil
80+}
pkg/upstream/rewrite_test.gonew file+60
@@ -0,0 +1,60 @@
1+package upstream
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "regexp"
7+
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter"
9+ . "github.com/onsi/ginkgo"
10+ . "github.com/onsi/ginkgo/extensions/table"
11+ . "github.com/onsi/gomega"
12+)
13+
14+var _ = Describe("Rewrite", func() {
15+ type rewritePathTableInput struct {
16+ rewriteRegex *regexp.Regexp
17+ rewriteTarget string
18+ requestTarget string
19+ expectedRequestURI string
20+ }
21+
22+ DescribeTable("should rewrite the request path",
23+ func(in rewritePathTableInput) {
24+ req := httptest.NewRequest("", in.requestTarget, nil)
25+ rw := httptest.NewRecorder()
26+
27+ var gotRequestURI string
28+ handler := newRewritePath(in.rewriteRegex, in.rewriteTarget, &pagewriter.WriterFuncs{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ gotRequestURI = r.RequestURI
30+ }))
31+ handler.ServeHTTP(rw, req)
32+
33+ Expect(gotRequestURI).To(Equal(in.expectedRequestURI))
34+ },
35+ Entry("when the path matches the regexp", rewritePathTableInput{
36+ rewriteRegex: regexp.MustCompile("^/http/(.*)"),
37+ rewriteTarget: "/$1",
38+ requestTarget: "http://example.com/http/foo/bar",
39+ expectedRequestURI: "http://example.com/foo/bar",
40+ }),
41+ Entry("when the path does not match the regexp", rewritePathTableInput{
42+ rewriteRegex: regexp.MustCompile("^/http/(.*)"),
43+ rewriteTarget: "/$1",
44+ requestTarget: "https://example.com/https/foo/bar",
45+ expectedRequestURI: "https://example.com/https/foo/bar",
46+ }),
47+ Entry("when the regexp is not anchored", rewritePathTableInput{
48+ rewriteRegex: regexp.MustCompile("/http/(.*)"),
49+ rewriteTarget: "/$1",
50+ requestTarget: "http://example.com/bar/http/foo/bar",
51+ expectedRequestURI: "http://example.com/bar/foo/bar",
52+ }),
53+ Entry("when the regexp is rewriting to a query", rewritePathTableInput{
54+ rewriteRegex: regexp.MustCompile(`/articles/([a-z0-9\-]*)`),
55+ rewriteTarget: "/article?id=$1",
56+ requestTarget: "http://example.com/articles/blog-2021-01-01",
57+ expectedRequestURI: "http://example.com/article?id=blog-2021-01-01",
58+ }),
59+ )
60+})
pkg/upstream/upstream_suite_test.go+1
@@ -59,6 +59,7 @@ const (
5959 acceptEncoding = "Accept-Encoding"
6060 applicationJSON = "application/json"
6161 textPlainUTF8 = "text/plain; charset=utf-8"
62+ textHTMLUTF8 = "text/html; charset=utf-8"
6263 gapAuth = "Gap-Auth"
6364 gapSignature = "Gap-Signature"
6465)