Repository: oauth2-proxy/oauth2-proxy
Pull request #997 (2021-01-18 10:36:01)

Use the raw url path when proxying upstream requests
This way escaped char's like /%2F/ will still work instead of returning
301. The proxy should ideally touch the request as little as possible /
needed.

## Description

Adds a new configuration flag to enable passing of the raw path to upstreams

## Motivation and Context

https://github.com/oauth2-proxy/oauth2-proxy/issues/844

## How Has This Been Tested?

a couple of new tests and running in our production setup for ~6month now.

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [X] My change requires a change to the documentation or CHANGELOG.
- [X] I have updated the documentation/CHANGELOG accordingly.
- [X] I have created a feature (non-master) branch for my PR.


JoelSpeed: I think this is actually a reasonable fix, did you ever look further into possible requests that it would break?
FStelzer: I've deployed this change into our production which proxies various admin tools / frontends and had no issue with any of them.
If something breaks by this change then it was probably broken before putting a proxy in front of it. Generally i'm in favor of having the proxy touch the user request as little as possible.
JoelSpeed: Ok well that seems reasonable, I've had a bit of a think about this and I think it's probably the right way to go, would like to get @NickMeves opinion as well though, Nick can you think of any negative affects of this change? 
NickMeves: I've definitely run into this in the past (with Apache & python webservers if I recall). This auto `%2F` to `/` conversion is a widespread problem.

My only concern is the breaking aspect of this change -- us flipping this over would break upstream systems that potentially accounted for this issue in their logic?

Is this minor enough of a breaking change to squeeze into a minor release?
FStelzer: To rely on the current behaviour you'd have to explicitly link to sth like /%2F/  and expect the backend to just get /
That doesen't really make sense since you could link / in the first place.
If you were working around this behaviour you would probably change the encoding for the / altogether (or put it into a parameter) and that should continue to work.
I think the behaviour change regarding %2F and / is rather safe. I'm not sure though which other encoded char's might now be passed that were removed/decoded/rewritten by the go url parser before.
NickMeves: > To rely on the current behaviour you'd have to explicitly link to sth like /%2F/ and expect the backend to just get /
> That doesen't really make sense since you could link / in the first place.
> If you were working around this behaviour you would probably change the encoding for the / altogether (or put it into a parameter) and that should continue to work.
> I think the behaviour change regarding %2F and / is rather safe. I'm not sure though which other encoded char's might now be passed that were removed/decoded/rewritten by the go url parser before.

I ran into this a lot build security threat indicator systems. The threat indicator (IP, email, domain, URL) would be the last dynamic part of a path in a REST API on the graph database structure (e.g. a route in my backend like this - `/vertex/indicator/:threat`)

Where `:threat` being the last part, I recall I could handle any nested `/` in there I received from my frontend proxy layers. Not saying that was the best (or even a good design 😅 ) -- but that's a sample scenario I've had where you can expect dynamic `%2F` to `/` conversions where I worked around it on the backend because I didn't have access to tweak the corporate hosting layer I was stuck with.

If this change happened under my nose without being marked as breaking - I would've needed to add URL decoding logic on the `:threat` variable in the backend.

Now the question - is this situation minor enough of a breaking change to warrant this sneaking into a minor release instead of waiting for the next major?
NickMeves: This got me curious for why all these webserver implementations do this by default and don't allow encoded slashes. I came across this blurb:

>Both Apache and Tomcat intentionally reject URIs with an encoded slash (%2F for / and %5C for \) to prevent possible security vulnerabilities such as CVE-2007-0450 and CVE-2007-0450 related attacks.

I'll look into those CVEs and think about the threat vectors
FStelzer: Hm. Let me check my patch again. My intention was not to decode the %2F in
the url but pass it through as is. I believe the proxy should not touch the
incoming request at all.

Nick Meves <notifications@github.com> schrieb am Sa. 20. Feb. 2021 um 18:12:

> This got me curious for why all these webserver implementations do this by
> default and don't allow encoded slashes. I came across this blurb:
>
> Both Apache and Tomcat intentionally reject URIs with an encoded slash
> (%2F for / and %5C for ) to prevent possible security vulnerabilities such
> as CVE-2007-0450 and CVE-2007-0450 related attacks.
>
> I'll look into those CVEs and think about the threat vectors
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/oauth2-proxy/oauth2-proxy/pull/997#issuecomment-782716925>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAEJ46WOGKYEGHZK4QPBZVLS77UQHANCNFSM4WHC5EMA>
> .
>

NickMeves: > Hm. Let me check my patch again. My intention was not to decode the %2F in the url but pass it through as is. I believe the proxy should not touch the incoming request at all.

Yeah, I think that's the attack vector various web servers have in mind when they refuse to proxy `%2F` and auto-encode it to `/` -- attackers stuffing in `%2F` to try to do directory traversal to a backend layer that turns it into a `/` potentially where a frontend security filtering layer would miss it.

You can see that attack behavior a ton in our open redirect attack test cases: https://github.com/oauth2-proxy/oauth2-proxy/blob/master/testdata/openredirects.txt


NickMeves: I think we need to look at the underlying Go HTTP server and see how it turns raw HTTP protocol bytes into its `req.URL` object we receive to better understand what is in the `URL.Path` and `URL.RawPath`.

Then if we went down this route - I think we would want a flag (e.g. something like `allowEncodedSlashes` that other webserver use for users to opt out of the security protections).

And finally, we would likely need our own function that only operates on encoded slashes -- I think `URL.EscapedPath()` will be mutating a lot of other characters looking at the source code.

I'm thinking since Go opted to turning `%2F` into `/` automatically in `http.Request` in their implementation, they must've been mindful of the same CVEs & security implecations that caused all other proxy servers to behave like this 🤔 
NickMeves: > I think we need to look at the underlying Go HTTP server and see how it turns raw HTTP protocol bytes into its `req.URL` object we receive to better understand what is in the `URL.Path` and `URL.RawPath`.

It looks like `ReadRequest` in `net/http/request.go` is the place to look.
NickMeves: This seems to be Go's implementation that we want to be mindful of - its comment has the description of our scenario: https://github.com/golang/go/blob/d4b26382342c98a95b85140b2863bc30c48edd68/src/net/url/url.go#L676
FStelzer: interestingly i looked into adding this config flag per upstream and found the following comment:
https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L147

@JoelSpeed do you remember what this referred to? this looks to me like it tried to fix the same thing?
I'm not sure about the go http/proxy stuff but i guess the client side http server part already did the rewriting/redirect of /%2F/ to / at this point?
If thats the case it would be difficult to add the setting per upstream :/
JoelSpeed: I didn't actually add that comment, just moved it from one package to another, it was originally introduced in https://github.com/bitly/oauth2_proxy/pull/17 which does seem to be trying to resolve the same issue, but maybe is now broken?
FStelzer: maybe back then the proxy did not allow for several upstreams / static file handlers and was not using a mux at all but proxying straight through?
anyway, i can add a flag to add the desired behavior to the mux (but not individual upstreams). Is there a clever way of setting specific options in the test framework? i'm not familiar with ginkgo - but maybe there are other tests already that test flags in both directions?
JoelSpeed: > anyway, i can add a flag to add the desired behavior to the mux (but not individual upstreams). Is there a clever way of setting specific options in the test framework? i'm not familiar with ginkgo - but maybe there are other tests already that test flags in both directions?

Rather than relying on the flag, implement a new option on the `Upstreams` struct (you might need to change this from a list to a struct with a list inside it), then in the tests you can just set that value as you construct the new upstream proxy
FStelzer: Sorry for the late reply. I was on vacation and offline for the last 2 weeks :)

> Rather than relying on the flag, implement a new option on the `Upstreams` struct (you might need to change this from a list to a struct with a list inside it), then in the tests you can just set that value as you construct the new upstream proxy

Hmm. changing it to a struct would actually change the existing config file format. There might be more mux related options in the future so maybe a seperate config struct UpstreamOptions for general settings might make migration easier?

i can set these easily via the test (and already do with the simple flag i added for now). My problem was more that the test framework sets everything up first (including all the options) and then runs all the tests on the same options. What i would like would be to run a different set of tests with different options and i'm not sure how to do this ginkgo. Do i put another Describe("Proxy Suite - flagX") in the same test file?
JoelSpeed: > changing it to a struct would actually change the existing config file format.

The structured config is currently in alpha stage, so we can make breaking changes and that's ok! (I put a big red warning at the top of the docs about it! :D)

We could do something like

```
upstreams:
  option1: foo
  configs:
    - <existing upstreams>
```
Not sure about how to name this tbh, @NickMeves any thoughts on this change? Another option is `upstreamConfig` and then `upstreams` within that

> Do i put another Describe("Proxy Suite - flagX") in the same test file?

Yeah i think in this case, if it is going to affect all of the upstreams, then you would want it to.

I guess the alternative to all of this, is whether this should be an option per upstream, that would then make it not a breaking change and would be easier to set up a new backend for the test suite. Does this actually effect file or static paths? Or is it really just that we need to set something in the proxy director of the http proxy?

I guess we need to work out exactly where the 301 is coming from :thinking:

JoelSpeed: Thinking about this, I was recently investigating replacing the mux with gorilla/mux, I did some work on this here https://github.com/oauth2-proxy/oauth2-proxy/pull/1060, I wonder if it might be easier to resolve this issue there. I notice they have a `StrictSlash` option, they have a `SkipClean` option for various routes, which we may be able to use to prevent the cleaning behaviour that is breaking these requests
JoelSpeed: Looked into this a bit today and while it would work with gorilla mux, we can only set it for every upstream, we can't set it for individual upstreams at the moment. Though, there is a stale PR in place that would allow this to work https://github.com/gorilla/mux/pull/463/. I'll see if there's anything I can do to help get that PR merged
FStelzer: changed the upstream config to a struct with the upstream list inside. the current change has no effect yet and all tests succeed (not sure why the github ci fails). If this is ok i can readd the rawpath change and tests at least as a setting for all upstreams.
when gorilla/mux supports the change per upstream the config also allows setting this then.

let me know if i can proceed. just wanted to check before adjusting the rest.

regarding the tests: there really should be a gomega wildcard/regex matcher used instead of generic errors that need to fully match. otherwise quite a few tests will keep breaking all the time (e.g. the main_test check for a broken yaml that references a specific test config line number)
I'm not familiar with ginkgo/gomega but when adding tests for the new flag i can look into it.
FStelzer: hm... weird... i've readded the escapedpath line and the unit tests work fine but i still get the 301 redirect from the oauth proxy....
has something about the request path changed in the meantime and we hit the same problem somewhere else as well?
JoelSpeed: Before we proceed with this, I want to spend a little time working out how this will interact with #1128 and #1060. We are basically ripping out and replacing the mux's in the regular and upstream cases, so I don't want to introduce this change now unless we can keep the behaviour when we merge these. Apologies for the delays in this, will hopefully get to fixing those up in the next couple of weeks 
FStelzer: the actual change to the upstream proxy is just a single line so i don't think there will be much of an impact but we can wait for the others to merge and i can rebase this one
JoelSpeed: One of my concerns/thoughts here is that gorilla mux behaves very differently to the standard mux. I think it may even "just work" and not return the 301 redirects when it finds encoded characters, so we may not need this. But as I say, will keep this in mind and double check what we are doing
github-actions[bot]: This pull request has been inactive for 60 days. If the pull request is still relevant please comment to re-activate the pull request. If no action is taken within 7 days, the pull request will be marked closed.
FStelzer: whats the status on the mux change? i see there has been no release since last time
JoelSpeed: Mux change has been merged, we are planning to do the next release soon
FStelzer: > Mux change has been merged, we are planning to do the next release soon

a quick test with the current master shows that the problem seems to be gone :)
maybe still worth a specific unit test? (if not already present)
JoelSpeed: > maybe still worth a specific unit test?

Not sure if there is one specifically that covers this, would you be able to look into adding one?
FStelzer: > > maybe still worth a specific unit test?
> 
> Not sure if there is one specifically that covers this, would you be able to look into adding one?

i'll look into it. but this seems a bit more involved after some initial testing... 
if i add the test to proxy_test like i did in this diff then it will fail and redirect to a path without the encoded / like the earlier behaviour....
i guess i have to find out first why it actually works now since the upstream proxy is behaving like before ...
FStelzer: > > maybe still worth a specific unit test?
> 
> Not sure if there is one specifically that covers this, would you be able to look into adding one?

sorry about my earlier messages.. its hard to test this with an actual proxy since caching sometimes gets in the way. i had to create my intial test setup again.

my inital check claiming that the problem is gone was wrong. the problem still exists and the mux change actually makes it worse. now both the frontend mux and the upstream mux both rewrite the %2F urls.
To make it work i have to use "r := mux.NewRouter().UseEncodedPath()" for both.
This obviously makes configuring this much harder. since the earlier version without the new mux at behaved differently for the frontend it might be a good idea to use UseEncodedPath() there in any case?
all tests are still valid with it.
JoelSpeed: > now both the frontend mux and the upstream mux both rewrite the %2F urls.

This was my suspicion and hence my hesitation in waiting.

I haven't looked into the `.UseEncodedPath()` option but if the existing tests are passing, maybe it's not necessarily a bad thing to include it there always. Realistically any path that doesn't match one of the built in paths, will be transformed by the second mux anyway. So I think you might be right, this may not be a breaking change to include it on the front mux
FStelzer: > > now both the frontend mux and the upstream mux both rewrite the %2F urls.
> 
> This was my suspicion and hence my hesitation in waiting.
> 
> I haven't looked into the `.UseEncodedPath()` option but if the existing tests are passing, maybe it's not necessarily a bad thing to include it there always. Realistically any path that doesn't match one of the built in paths, will be transformed by the second mux anyway. So I think you might be right, this may not be a breaking change to include it on the front mux

sounds good. the UseEncodedPath() Option does the same thing my patch did manually. it sets an internal mux flag that then triggers:
path = req.URL.EscapedPath()
within ServeHTTP.
I'll prepare a new patch and simply set the option on the frontend and make it configurable on the upstream
FStelzer: ok. i have pushed a new version against current master.
this diff changes Upstreams to a struct to enable config options for the mux and adds the first one for enabling UseEncodedPath()
The proxy_test rewrite might look a bit confusing at first. But by generating the upstreams for every test we can test different mux settings here as well.
FStelzer: ok, this should fix the checked in docs. i had no idea these were generated automatically...
FStelzer: anything i can do to help this along?
without this patch the proxy is unusable for something like the rabbitmq admin panel.
FStelzer: i just pushed a new version leaving the fixup commits in place and adding a rename commit.
it's probably easier to leave the rename on its own instead of squashing it into the one changing the struct.
if we want a really clean history i could rename it first and then change it into a struct afterwards with the new name for the member. doesn't really make a big difference though and i think this is easier to review. the fixups can of course be squashed though

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc1f773a..893f2922 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@
 - [#1317](https://github.com/oauth2-proxy/oauth2-proxy/pull/1317) Fix incorrect `</form>` tag on the sing_in page when *not* using a custom template (@jord1e)
 - [#1330](https://github.com/oauth2-proxy/oauth2-proxy/pull/1330) Allow specifying URL as input for custom sign in logo (@MaikuMori)
 - [#1357](https://github.com/oauth2-proxy/oauth2-proxy/pull/1357) Fix unsafe access to session variable (@harzallah)
+- [#997](https://github.com/oauth2-proxy/oauth2-proxy/pull/997) Allow passing the raw url path when proxying upstream requests - e.g. /%2F/ (@FStelzer)
 
 # V7.1.3
 
diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md
index 7381e1c0..f13ea131 100644
--- a/docs/docs/configuration/alpha_config.md
+++ b/docs/docs/configuration/alpha_config.md
@@ -124,7 +124,7 @@ They may change between releases without notice.
 
 | Field | Type | Description |
 | ----- | ---- | ----------- |
-| `upstreams` | _[Upstreams](#upstreams)_ | Upstreams is used to configure upstream servers.<br/>Once a user is authenticated, requests to the server will be proxied to<br/>these upstream servers based on the path mappings defined in this list. |
+| `upstreamConfig` | _[UpstreamConfig](#upstreamconfig)_ | UpstreamConfig is used to configure upstream servers.<br/>Once a user is authenticated, requests to the server will be proxied to<br/>these upstream servers based on the path mappings defined in this list. |
 | `injectRequestHeaders` | _[[]Header](#header)_ | InjectRequestHeaders is used to configure headers that should be added<br/>to requests to upstream servers.<br/>Headers may source values from either the authenticated user's session<br/>or from a static secret value. |
 | `injectResponseHeaders` | _[[]Header](#header)_ | InjectResponseHeaders is used to configure headers that should be added<br/>to responses from the proxy.<br/>This is typically used when using the proxy as an external authentication<br/>provider in conjunction with another proxy such as NGINX and its<br/>auth_request module.<br/>Headers may source values from either the authenticated user's session<br/>or from a static secret value. |
 | `server` | _[Server](#server)_ | Server is used to configure the HTTP(S) server for the proxy application.<br/>You may choose to run both HTTP and HTTPS servers simultaneously.<br/>This can be done by setting the BindAddress and the SecureBindAddress simultaneously.<br/>To use the secure server you must configure a TLS certificate and key. |
@@ -364,7 +364,7 @@ TLS contains the information for loading a TLS certifcate and key.
 
 ### Upstream
 
-(**Appears on:** [Upstreams](#upstreams))
+(**Appears on:** [UpstreamConfig](#upstreamconfig))
 
 Upstream represents the configuration for an upstream server.
 Requests will be proxied to this upstream if the path matches the request path.
@@ -382,11 +382,13 @@ Requests will be proxied to this upstream if the path matches the request path.
 | `passHostHeader` | _bool_ | PassHostHeader determines whether the request host header should be proxied<br/>to the upstream server.<br/>Defaults to true. |
 | `proxyWebSockets` | _bool_ | ProxyWebSockets enables proxying of websockets to upstream servers<br/>Defaults to true. |
 
-### Upstreams
-
-#### ([[]Upstream](#upstream) alias)
+### UpstreamConfig
 
 (**Appears on:** [AlphaOptions](#alphaoptions))
 
-Upstreams is a collection of definitions for upstream servers.
+UpstreamConfig is a collection of definitions for upstream servers.
 
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `proxyRawPath` | _bool_ | ProxyRawPath will pass the raw url path to upstream allowing for url's<br/>like: "/%2F/" which would otherwise be redirected to "/" |
+| `upstreams` | _[[]Upstream](#upstream)_ | Upstreams represents the configuration for the upstream servers.<br/>Requests will be proxied to this upstream if the path matches the request path. |
diff --git a/oauthproxy.go b/oauthproxy.go
index e2d20ed6..fb6ef0bc 100644
--- a/oauthproxy.go
+++ b/oauthproxy.go
@@ -265,7 +265,9 @@ func (p *OAuthProxy) setupServer(opts *options.Options) error {
 }
 
 func (p *OAuthProxy) buildServeMux(proxyPrefix string) {
-	r := mux.NewRouter()
+	// Use the encoded path here so we can have the option to pass it on in the upstream mux.
+	// Otherwise something like /%2F/ would be redirected to / here already.
+	r := mux.NewRouter().UseEncodedPath()
 	// Everything served by the router must go through the preAuthChain first.
 	r.Use(p.preAuthChain.Then)
 
diff --git a/pkg/apis/options/alpha_options.go b/pkg/apis/options/alpha_options.go
index ecfd81c8..04769d7f 100644
--- a/pkg/apis/options/alpha_options.go
+++ b/pkg/apis/options/alpha_options.go
@@ -9,10 +9,10 @@ package options
 // They may change between releases without notice.
 // :::
 type AlphaOptions struct {
-	// Upstreams is used to configure upstream servers.
+	// UpstreamConfig is used to configure upstream servers.
 	// Once a user is authenticated, requests to the server will be proxied to
 	// these upstream servers based on the path mappings defined in this list.
-	Upstreams Upstreams `json:"upstreams,omitempty"`
+	UpstreamConfig UpstreamConfig `json:"upstreamConfig,omitempty"`
 
 	// InjectRequestHeaders is used to configure headers that should be added
 	// to requests to upstream servers.
@@ -48,19 +48,18 @@ type AlphaOptions struct {
 // MergeInto replaces alpha options in the Options struct with the values
 // from the AlphaOptions
 func (a *AlphaOptions) MergeInto(opts *Options) {
-	opts.UpstreamServers = a.Upstreams
+	opts.UpstreamServers = a.UpstreamConfig
 	opts.InjectRequestHeaders = a.InjectRequestHeaders
 	opts.InjectResponseHeaders = a.InjectResponseHeaders
 	opts.Server = a.Server
 	opts.MetricsServer = a.MetricsServer
 	opts.Providers = a.Providers
-
 }
 
 // ExtractFrom populates the fields in the AlphaOptions with the values from
 // the Options
 func (a *AlphaOptions) ExtractFrom(opts *Options) {
-	a.Upstreams = opts.UpstreamServers
+	a.UpstreamConfig = opts.UpstreamServers
 	a.InjectRequestHeaders = opts.InjectRequestHeaders
 	a.InjectResponseHeaders = opts.InjectResponseHeaders
 	a.Server = opts.Server
diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go
index cf67dedd..3441fd41 100644
--- a/pkg/apis/options/legacy_options.go
+++ b/pkg/apis/options/legacy_options.go
@@ -114,13 +114,13 @@ func legacyUpstreamsFlagSet() *pflag.FlagSet {
 	return flagSet
 }
 
-func (l *LegacyUpstreams) convert() (Upstreams, error) {
-	upstreams := Upstreams{}
+func (l *LegacyUpstreams) convert() (UpstreamConfig, error) {
+	upstreams := UpstreamConfig{}
 
 	for _, upstreamString := range l.Upstreams {
 		u, err := url.Parse(upstreamString)
 		if err != nil {
-			return nil, fmt.Errorf("could not parse upstream %q: %v", upstreamString, err)
+			return UpstreamConfig{}, fmt.Errorf("could not parse upstream %q: %v", upstreamString, err)
 		}
 
 		if u.Path == "" {
@@ -169,7 +169,7 @@ func (l *LegacyUpstreams) convert() (Upstreams, error) {
 			upstream.FlushInterval = nil
 		}
 
-		upstreams = append(upstreams, upstream)
+		upstreams.Upstreams = append(upstreams.Upstreams, upstream)
 	}
 
 	return upstreams, nil
diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go
index 0fd2ece1..d4bb4312 100644
--- a/pkg/apis/options/options.go
+++ b/pkg/apis/options/options.go
@@ -41,7 +41,7 @@ type Options struct {
 
 	// Not used in the legacy config, name not allowed to match an external key (upstreams)
 	// TODO(JoelSpeed): Rename when legacy config is removed
-	UpstreamServers Upstreams `cfg:",internal"`
+	UpstreamServers UpstreamConfig `cfg:",internal"`
 
 	InjectRequestHeaders  []Header `cfg:",internal"`
 	InjectResponseHeaders []Header `cfg:",internal"`
diff --git a/pkg/apis/options/upstreams.go b/pkg/apis/options/upstreams.go
index 1977c8da..9d735f13 100644
--- a/pkg/apis/options/upstreams.go
+++ b/pkg/apis/options/upstreams.go
@@ -7,8 +7,16 @@ const (
 	DefaultUpstreamFlushInterval = 1 * time.Second
 )
 
-// Upstreams is a collection of definitions for upstream servers.
-type Upstreams []Upstream
+// UpstreamConfig is a collection of definitions for upstream servers.
+type UpstreamConfig struct {
+	// ProxyRawPath will pass the raw url path to upstream allowing for url's
+	// like: "/%2F/" which would otherwise be redirected to "/"
+	ProxyRawPath bool `json:"proxyRawPath,omitempty"`
+
+	// Upstreams represents the configuration for the upstream servers.
+	// Requests will be proxied to this upstream if the path matches the request path.
+	Upstreams []Upstream `json:"upstreams,omitempty"`
+}
 
 // Upstream represents the configuration for an upstream server.
 // Requests will be proxied to this upstream if the path matches the request path.
diff --git a/pkg/upstream/proxy.go b/pkg/upstream/proxy.go
index 594824e2..eabb47ff 100644
--- a/pkg/upstream/proxy.go
+++ b/pkg/upstream/proxy.go
@@ -22,12 +22,16 @@ type ProxyErrorHandler func(http.ResponseWriter, *http.Request, error)
 
 // NewProxy creates a new multiUpstreamProxy that can serve requests directed to
 // multiple upstreams.
-func NewProxy(upstreams options.Upstreams, sigData *options.SignatureData, writer pagewriter.Writer) (http.Handler, error) {
+func NewProxy(upstreams options.UpstreamConfig, sigData *options.SignatureData, writer pagewriter.Writer) (http.Handler, error) {
 	m := &multiUpstreamProxy{
 		serveMux: mux.NewRouter(),
 	}
 
-	for _, upstream := range sortByPathLongest(upstreams) {
+	if upstreams.ProxyRawPath {
+		m.serveMux.UseEncodedPath()
+	}
+
+	for _, upstream := range sortByPathLongest(upstreams.Upstreams) {
 		if upstream.Static {
 			if err := m.registerStaticResponseHandler(upstream, writer); err != nil {
 				return nil, fmt.Errorf("could not register static upstream %q: %v", upstream.ID, err)
@@ -153,7 +157,7 @@ func registerTrailingSlashHandler(serveMux *mux.Router) {
 // precedence (note this is the input to the rewrite logic).
 // This does not account for when a rewrite would actually make the path shorter.
 // This should maintain the sorting behaviour of the standard go serve mux.
-func sortByPathLongest(in options.Upstreams) options.Upstreams {
+func sortByPathLongest(in []options.Upstream) []options.Upstream {
 	sort.Slice(in, func(i, j int) bool {
 		iRW := in[i].RewriteTarget
 		jRW := in[j].RewriteTarget
diff --git a/pkg/validation/upstreams.go b/pkg/validation/upstreams.go
index 7bd6b2d5..ba1e216a 100644
--- a/pkg/validation/upstreams.go
+++ b/pkg/validation/upstreams.go
@@ -7,12 +7,12 @@ import (
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
 )
 
-func validateUpstreams(upstreams options.Upstreams) []string {
+func validateUpstreams(upstreams options.UpstreamConfig) []string {
 	msgs := []string{}
 	ids := make(map[string]struct{})
 	paths := make(map[string]struct{})
 
-	for _, upstream := range upstreams {
+	for _, upstream := range upstreams.Upstreams {
 		msgs = append(msgs, validateUpstream(upstream, ids, paths)...)
 	}
 


Test patch:
diff --git a/main_test.go b/main_test.go
index e40243ef..4fa9efbf 100644
--- a/main_test.go
+++ b/main_test.go
@@ -2,8 +2,10 @@ package main
 
 import (
 	"errors"
+	"fmt"
 	"io/ioutil"
 	"os"
+	"strings"
 	"time"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -24,7 +26,9 @@ client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK"
 `
 
 	const testAlphaConfig = `
-upstreams:
+upstreamConfig:
+  proxyrawpath: false
+  upstreams:
   - id: /
     path: /
     uri: http://httpbin
@@ -100,14 +104,16 @@ redirect_url="http://localhost:4180/oauth2/callback"
 		opts.Cookie.Secure = false
 		opts.RawRedirectURL = "http://localhost:4180/oauth2/callback"
 
-		opts.UpstreamServers = options.Upstreams{
-			{
-				ID:              "/",
-				Path:            "/",
-				URI:             "http://httpbin",
-				FlushInterval:   durationPtr(options.DefaultUpstreamFlushInterval),
-				PassHostHeader:  boolPtr(true),
-				ProxyWebSockets: boolPtr(true),
+		opts.UpstreamServers = options.UpstreamConfig{
+			Upstreams: []options.Upstream{
+				{
+					ID:              "/",
+					Path:            "/",
+					URI:             "http://httpbin",
+					FlushInterval:   durationPtr(options.DefaultUpstreamFlushInterval),
+					PassHostHeader:  boolPtr(true),
+					ProxyWebSockets: boolPtr(true),
+				},
 			},
 		}
 
@@ -130,7 +136,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 		opts.InjectResponseHeaders = append(opts.InjectResponseHeaders, authHeader)
 
 		opts.Providers = options.Providers{
-			{
+			options.Provider{
 				ID:           "google=oauth2-proxy",
 				Type:         "google",
 				ClientSecret: "b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK",
@@ -230,7 +236,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 			configContent:      testCoreConfig,
 			alphaConfigContent: testAlphaConfig + ":",
 			expectedOptions:    func() *options.Options { return nil },
-			expectedErr:        errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 49: did not find expected key"),
+			expectedErr:        fmt.Errorf("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line %d: did not find expected key", strings.Count(testAlphaConfig, "\n")),
 		}),
 		Entry("with alpha configuration and bad core configuration", loadConfigurationTableInput{
 			configContent:      testCoreConfig + "unknown_field=\"something\"",
diff --git a/oauthproxy_test.go b/oauthproxy_test.go
index cb1dceed..9add52ed 100644
--- a/oauthproxy_test.go
+++ b/oauthproxy_test.go
@@ -197,11 +197,13 @@ func TestBasicAuthPassword(t *testing.T) {
 
 	basicAuthPassword := "This is a secure password"
 	opts := baseTestOptions()
-	opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   providerServer.URL,
-			Path: "/",
-			URI:  providerServer.URL,
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   providerServer.URL,
+				Path: "/",
+				URI:  providerServer.URL,
+			},
 		},
 	}
 
@@ -346,15 +348,17 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTe
 		}))
 
 	patt.opts = baseTestOptions()
-	patt.opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   patt.providerServer.URL,
-			Path: "/",
-			URI:  patt.providerServer.URL,
+	patt.opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   patt.providerServer.URL,
+				Path: "/",
+				URI:  patt.providerServer.URL,
+			},
 		},
 	}
 	if opts.ProxyUpstream.ID != "" {
-		patt.opts.UpstreamServers = append(patt.opts.UpstreamServers, opts.ProxyUpstream)
+		patt.opts.UpstreamServers.Upstreams = append(patt.opts.UpstreamServers.Upstreams, opts.ProxyUpstream)
 	}
 
 	patt.opts.Cookie.Secure = false
@@ -915,6 +919,15 @@ func TestUserInfoEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
 }
 
+func TestEncodedUrlsStayEncoded(t *testing.T) {
+	encodeTest, err := NewSignInPageTest(false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	code, _ := encodeTest.GetEndpoint("/%2F/test1/%2F/test2")
+	assert.Equal(t, 403, code)
+}
+
 func NewAuthOnlyEndpointTest(querystring string, modifiers ...OptionsModifier) (*ProcessCookieTest, error) {
 	pcTest, err := NewProcessCookieTestWithOptionsModifiers(modifiers...)
 	if err != nil {
@@ -1260,11 +1273,13 @@ func TestAuthSkippedForPreflightRequests(t *testing.T) {
 	t.Cleanup(upstreamServer.Close)
 
 	opts := baseTestOptions()
-	opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   upstreamServer.URL,
-			Path: "/",
-			URI:  upstreamServer.URL,
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
 		},
 	}
 	opts.SkipAuthPreflight = true
@@ -1335,11 +1350,13 @@ func NewSignatureTest() (*SignatureTest, error) {
 	if err != nil {
 		return nil, err
 	}
-	opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   upstreamServer.URL,
-			Path: "/",
-			URI:  upstreamServer.URL,
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
 		},
 	}
 
@@ -1771,11 +1788,13 @@ func Test_noCacheHeaders(t *testing.T) {
 	t.Cleanup(upstreamServer.Close)
 
 	opts := baseTestOptions()
-	opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   upstreamServer.URL,
-			Path: "/",
-			URI:  upstreamServer.URL,
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
 		},
 	}
 	opts.SkipAuthRegex = []string{".*"}
@@ -2041,11 +2060,13 @@ func TestTrustedIPs(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			opts := baseTestOptions()
-			opts.UpstreamServers = options.Upstreams{
-				{
-					ID:     "static",
-					Path:   "/",
-					Static: true,
+			opts.UpstreamServers = options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:     "static",
+						Path:   "/",
+						Static: true,
+					},
 				},
 			}
 			opts.TrustedIPs = tt.trustedIPs
@@ -2234,11 +2255,13 @@ func TestAllowedRequest(t *testing.T) {
 	t.Cleanup(upstreamServer.Close)
 
 	opts := baseTestOptions()
-	opts.UpstreamServers = options.Upstreams{
-		{
-			ID:   upstreamServer.URL,
-			Path: "/",
-			URI:  upstreamServer.URL,
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
 		},
 	}
 	opts.SkipAuthRegex = []string{
@@ -2349,11 +2372,13 @@ func TestProxyAllowedGroups(t *testing.T) {
 
 			test, err := NewProcessCookieTestWithOptionsModifiers(func(opts *options.Options) {
 				opts.Providers[0].AllowedGroups = tt.allowedGroups
-				opts.UpstreamServers = options.Upstreams{
-					{
-						ID:   upstreamServer.URL,
-						Path: "/",
-						URI:  upstreamServer.URL,
+				opts.UpstreamServers = options.UpstreamConfig{
+					Upstreams: []options.Upstream{
+						{
+							ID:   upstreamServer.URL,
+							Path: "/",
+							URI:  upstreamServer.URL,
+						},
 					},
 				}
 			})
diff --git a/pkg/apis/options/legacy_options_test.go b/pkg/apis/options/legacy_options_test.go
index fb5b816a..ecf48494 100644
--- a/pkg/apis/options/legacy_options_test.go
+++ b/pkg/apis/options/legacy_options_test.go
@@ -26,35 +26,37 @@ var _ = Describe("Legacy Options", func() {
 
 			truth := true
 			staticCode := 204
-			opts.UpstreamServers = Upstreams{
-				{
-					ID:                    "/baz",
-					Path:                  "/baz",
-					URI:                   "http://foo.bar/baz",
-					FlushInterval:         &flushInterval,
-					InsecureSkipTLSVerify: true,
-					PassHostHeader:        &truth,
-					ProxyWebSockets:       &truth,
-				},
-				{
-					ID:                    "/bar",
-					Path:                  "/bar",
-					URI:                   "file:///var/lib/website",
-					FlushInterval:         &flushInterval,
-					InsecureSkipTLSVerify: true,
-					PassHostHeader:        &truth,
-					ProxyWebSockets:       &truth,
-				},
-				{
-					ID:                    "static://204",
-					Path:                  "/",
-					URI:                   "",
-					Static:                true,
-					StaticCode:            &staticCode,
-					FlushInterval:         nil,
-					InsecureSkipTLSVerify: false,
-					PassHostHeader:        nil,
-					ProxyWebSockets:       nil,
+			opts.UpstreamServers = UpstreamConfig{
+				Upstreams: []Upstream{
+					{
+						ID:                    "/baz",
+						Path:                  "/baz",
+						URI:                   "http://foo.bar/baz",
+						FlushInterval:         &flushInterval,
+						InsecureSkipTLSVerify: true,
+						PassHostHeader:        &truth,
+						ProxyWebSockets:       &truth,
+					},
+					{
+						ID:                    "/bar",
+						Path:                  "/bar",
+						URI:                   "file:///var/lib/website",
+						FlushInterval:         &flushInterval,
+						InsecureSkipTLSVerify: true,
+						PassHostHeader:        &truth,
+						ProxyWebSockets:       &truth,
+					},
+					{
+						ID:                    "static://204",
+						Path:                  "/",
+						URI:                   "",
+						Static:                true,
+						StaticCode:            &staticCode,
+						FlushInterval:         nil,
+						InsecureSkipTLSVerify: false,
+						PassHostHeader:        nil,
+						ProxyWebSockets:       nil,
+					},
 				},
 			}
 
@@ -124,7 +126,7 @@ var _ = Describe("Legacy Options", func() {
 	Context("Legacy Upstreams", func() {
 		type convertUpstreamsTableInput struct {
 			upstreamStrings   []string
-			expectedUpstreams Upstreams
+			expectedUpstreams []Upstream
 			errMsg            string
 		}
 
@@ -219,51 +221,51 @@ var _ = Describe("Legacy Options", func() {
 					Expect(err).ToNot(HaveOccurred())
 				}
 
-				Expect(upstreams).To(ConsistOf(in.expectedUpstreams))
+				Expect(upstreams.Upstreams).To(ConsistOf(in.expectedUpstreams))
 			},
 			Entry("with no upstreams", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{},
-				expectedUpstreams: Upstreams{},
+				expectedUpstreams: []Upstream{},
 				errMsg:            "",
 			}),
 			Entry("with a valid HTTP upstream", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{validHTTP},
-				expectedUpstreams: Upstreams{validHTTPUpstream},
+				expectedUpstreams: []Upstream{validHTTPUpstream},
 				errMsg:            "",
 			}),
 			Entry("with a HTTP upstream with an empty path", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{emptyPathHTTP},
-				expectedUpstreams: Upstreams{emptyPathHTTPUpstream},
+				expectedUpstreams: []Upstream{emptyPathHTTPUpstream},
 				errMsg:            "",
 			}),
 			Entry("with a valid File upstream with a fragment", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{validFileWithFragment},
-				expectedUpstreams: Upstreams{validFileWithFragmentUpstream},
+				expectedUpstreams: []Upstream{validFileWithFragmentUpstream},
 				errMsg:            "",
 			}),
 			Entry("with a valid static upstream", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{validStatic},
-				expectedUpstreams: Upstreams{validStaticUpstream},
+				expectedUpstreams: []Upstream{validStaticUpstream},
 				errMsg:            "",
 			}),
 			Entry("with an invalid static upstream, code is 200", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{invalidStatic},
-				expectedUpstreams: Upstreams{invalidStaticUpstream},
+				expectedUpstreams: []Upstream{invalidStaticUpstream},
 				errMsg:            "",
 			}),
 			Entry("with an invalid HTTP upstream", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{invalidHTTP},
-				expectedUpstreams: Upstreams{},
+				expectedUpstreams: []Upstream{},
 				errMsg:            invalidHTTPErrMsg,
 			}),
 			Entry("with an invalid HTTP upstream and other upstreams", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{validHTTP, invalidHTTP},
-				expectedUpstreams: Upstreams{},
+				expectedUpstreams: []Upstream{},
 				errMsg:            invalidHTTPErrMsg,
 			}),
 			Entry("with multiple valid upstreams", &convertUpstreamsTableInput{
 				upstreamStrings:   []string{validHTTP, validFileWithFragment, validStatic},
-				expectedUpstreams: Upstreams{validHTTPUpstream, validFileWithFragmentUpstream, validStaticUpstream},
+				expectedUpstreams: []Upstream{validHTTPUpstream, validFileWithFragmentUpstream, validStaticUpstream},
 				errMsg:            "",
 			}),
 		)
diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go
index dce5d2d9..335facf4 100644
--- a/pkg/apis/options/load_test.go
+++ b/pkg/apis/options/load_test.go
@@ -469,11 +469,12 @@ sub:
 
 	It("should load a full example AlphaOptions", func() {
 		config := []byte(`
-upstreams:
-- id: httpbin
-  path: /
-  uri: http://httpbin
-  flushInterval: 500ms
+upstreamConfig:
+  upstreams:
+  - id: httpbin
+    path: /
+    uri: http://httpbin
+    flushInterval: 500ms
 injectRequestHeaders:
 - name: X-Forwarded-User
   values:
@@ -502,12 +503,14 @@ injectResponseHeaders:
 		flushInterval := Duration(500 * time.Millisecond)
 
 		Expect(into).To(Equal(&AlphaOptions{
-			Upstreams: []Upstream{
-				{
-					ID:            "httpbin",
-					Path:          "/",
-					URI:           "http://httpbin",
-					FlushInterval: &flushInterval,
+			UpstreamConfig: UpstreamConfig{
+				Upstreams: []Upstream{
+					{
+						ID:            "httpbin",
+						Path:          "/",
+						URI:           "http://httpbin",
+						FlushInterval: &flushInterval,
+					},
 				},
 			},
 			InjectRequestHeaders: []Header{
diff --git a/pkg/upstream/proxy_test.go b/pkg/upstream/proxy_test.go
index 597f5422..87c27e4b 100644
--- a/pkg/upstream/proxy_test.go
+++ b/pkg/upstream/proxy_test.go
@@ -16,94 +16,94 @@ import (
 )
 
 var _ = Describe("Proxy Suite", func() {
-	var upstreamServer http.Handler
+	type proxyTableInput struct {
+		target    string
+		response  testHTTPResponse
+		upstream  string
+		upstreams options.UpstreamConfig
+	}
 
 	Context("multiUpstreamProxy", func() {
-		BeforeEach(func() {
-			sigData := &options.SignatureData{Hash: crypto.SHA256, Key: "secret"}
-
-			writer := &pagewriter.WriterFuncs{
-				ProxyErrorFunc: func(rw http.ResponseWriter, _ *http.Request, _ error) {
-					rw.WriteHeader(502)
-					rw.Write([]byte("Proxy Error"))
-				},
-			}
+		DescribeTable("Proxy ServeHTTP",
+			func(in *proxyTableInput) {
+				sigData := &options.SignatureData{Hash: crypto.SHA256, Key: "secret"}
 
-			ok := http.StatusOK
-			accepted := http.StatusAccepted
+				writer := &pagewriter.WriterFuncs{
+					ProxyErrorFunc: func(rw http.ResponseWriter, _ *http.Request, _ error) {
+						rw.WriteHeader(502)
+						rw.Write([]byte("Proxy Error"))
+					},
+				}
 
-			upstreams := options.Upstreams{
-				{
-					ID:   "http-backend",
-					Path: "/http/",
-					URI:  serverAddr,
-				},
-				{
-					ID:   "file-backend",
-					Path: "/files/",
-					URI:  fmt.Sprintf("file:///%s", filesDir),
-				},
-				{
-					ID:         "static-backend",
-					Path:       "/static/",
-					Static:     true,
-					StaticCode: &ok,
-				},
-				{
-					ID:         "static-backend-no-trailing-slash",
-					Path:       "/static",
-					Static:     true,
-					StaticCode: &accepted,
-				},
-				{
-					ID:         "static-backend-long",
-					Path:       "/static/long",
-					Static:     true,
-					StaticCode: &accepted,
-				},
-				{
-					ID:   "bad-http-backend",
-					Path: "/bad-http/",
-					URI:  "http://::1",
-				},
-				{
-					ID:         "single-path-backend",
-					Path:       "/single-path",
-					Static:     true,
-					StaticCode: &ok,
-				},
-				{
-					ID:            "backend-with-rewrite-prefix",
-					Path:          "^/rewrite-prefix/(.*)",
-					RewriteTarget: "/different/backend/path/$1",
-					URI:           serverAddr,
-				},
-				{
-					ID:   "double-match-plain",
-					Path: "/double-match/",
-					URI:  serverAddr,
-				},
-				{
-					ID:            "double-match-rewrite",
-					Path:          "^/double-match/(.*)",
-					RewriteTarget: "/double-match/rewrite/$1",
-					URI:           serverAddr,
-				},
-			}
+				ok := http.StatusOK
+				accepted := http.StatusAccepted
 
-			var err error
-			upstreamServer, err = NewProxy(upstreams, sigData, writer)
-			Expect(err).ToNot(HaveOccurred())
-		})
+				// Allows for specifying settings and even individual upstreams for specific tests and uses the default upstreams/configs otherwise
+				upstreams := in.upstreams
+				if len(in.upstreams.Upstreams) == 0 {
+					upstreams.Upstreams = []options.Upstream{
+						{
+							ID:   "http-backend",
+							Path: "/http/",
+							URI:  serverAddr,
+						},
+						{
+							ID:   "file-backend",
+							Path: "/files/",
+							URI:  fmt.Sprintf("file:///%s", filesDir),
+						},
+						{
+							ID:         "static-backend",
+							Path:       "/static/",
+							Static:     true,
+							StaticCode: &ok,
+						},
+						{
+							ID:         "static-backend-no-trailing-slash",
+							Path:       "/static",
+							Static:     true,
+							StaticCode: &accepted,
+						},
+						{
+							ID:         "static-backend-long",
+							Path:       "/static/long",
+							Static:     true,
+							StaticCode: &accepted,
+						},
+						{
+							ID:   "bad-http-backend",
+							Path: "/bad-http/",
+							URI:  "http://::1",
+						},
+						{
+							ID:         "single-path-backend",
+							Path:       "/single-path",
+							Static:     true,
+							StaticCode: &ok,
+						},
+						{
+							ID:            "backend-with-rewrite-prefix",
+							Path:          "^/rewrite-prefix/(.*)",
+							RewriteTarget: "/different/backend/path/$1",
+							URI:           serverAddr,
+						},
+						{
+							ID:   "double-match-plain",
+							Path: "/double-match/",
+							URI:  serverAddr,
+						},
+						{
+							ID:            "double-match-rewrite",
+							Path:          "^/double-match/(.*)",
+							RewriteTarget: "/double-match/rewrite/$1",
+							URI:           serverAddr,
+						},
+					}
+				}
 
-		type proxyTableInput struct {
-			target   string
-			response testHTTPResponse
-			upstream string
-		}
+				upstreamServer, err := NewProxy(upstreams, sigData, writer)
+				Expect(err).ToNot(HaveOccurred())
 
-		DescribeTable("Proxy ServeHTTP",
-			func(in *proxyTableInput) {
 				req := middlewareapi.AddRequestScope(
 					httptest.NewRequest("", in.target, nil),
 					&middlewareapi.RequestScope{},
@@ -131,10 +131,12 @@ var _ = Describe("Proxy Suite", func() {
 				}
 
 				// Compare the reflected request to the upstream
-				request := testHTTPRequest{}
-				Expect(json.Unmarshal(body, &request)).To(Succeed())
-				testSanitizeRequestHeader(request.Header)
-				Expect(request).To(Equal(in.response.request))
+				if body != nil {
+					request := testHTTPRequest{}
+					Expect(json.Unmarshal(body, &request)).To(Succeed())
+					testSanitizeRequestHeader(request.Header)
+					Expect(request).To(Equal(in.response.request))
+				}
 			},
 			Entry("with a request to the HTTP service", &proxyTableInput{
 				target: "http://example.localhost/http/1234",
@@ -310,33 +312,58 @@ var _ = Describe("Proxy Suite", func() {
 				},
 				upstream: "double-match-rewrite",
 			}),
+			Entry("containing an escaped '/' without ProxyRawPath", &proxyTableInput{
+				target: "http://example.localhost/%2F/test1/%2F/test2",
+				response: testHTTPResponse{
+					code: 301,
+					header: map[string][]string{
+						"Location": {
+							"http://example.localhost/test1/test2",
+						},
+					},
+				},
+				upstream: "",
+			}),
+			Entry("containing an escaped '/' with ProxyRawPath", &proxyTableInput{
+				upstreams: options.UpstreamConfig{ProxyRawPath: true},
+				target:    "http://example.localhost/%2F/test1/%2F/test2",
+				response: testHTTPResponse{
+					code: 404,
+					header: map[string][]string{
+						"X-Content-Type-Options": {"nosniff"},
+						contentType:              {textPlainUTF8},
+					},
+					raw: "404 page not found\n",
+				},
+				upstream: "",
+			}),
 		)
 	})
 
 	Context("sortByPathLongest", func() {
 		type sortByPathLongestTableInput struct {
-			input          options.Upstreams
-			expectedOutput options.Upstreams
+			input          []options.Upstream
+			expectedOutput []options.Upstream
 		}
 
-		var httpPath = options.Upstream{
+		httpPath := options.Upstream{
 			Path: "/http/",
 		}
 
-		var httpSubPath = options.Upstream{
+		httpSubPath := options.Upstream{
 			Path: "/http/subpath/",
 		}
 
-		var longerPath = options.Upstream{
+		longerPath := options.Upstream{
 			Path: "/longer-than-http",
 		}
 
-		var shortPathWithRewrite = options.Upstream{
+		shortPathWithRewrite := options.Upstream{
 			Path:          "^/h/(.*)",
 			RewriteTarget: "/$1",
 		}
 
-		var shortSubPathWithRewrite = options.Upstream{
+		shortSubPathWithRewrite := options.Upstream{
 			Path:          "^/h/bar/(.*)",
 			RewriteTarget: "/$1",
 		}
@@ -346,40 +373,40 @@ var _ = Describe("Proxy Suite", func() {
 				Expect(sortByPathLongest(in.input)).To(Equal(in.expectedOutput))
 			},
 			Entry("with a mix of paths registered", sortByPathLongestTableInput{
-				input:          options.Upstreams{httpPath, httpSubPath, shortSubPathWithRewrite, longerPath, shortPathWithRewrite},
-				expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite, longerPath, httpSubPath, httpPath},
+				input:          []options.Upstream{httpPath, httpSubPath, shortSubPathWithRewrite, longerPath, shortPathWithRewrite},
+				expectedOutput: []options.Upstream{shortSubPathWithRewrite, shortPathWithRewrite, longerPath, httpSubPath, httpPath},
 			}),
 			Entry("when a subpath is registered (in order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{httpSubPath, httpPath},
-				expectedOutput: options.Upstreams{httpSubPath, httpPath},
+				input:          []options.Upstream{httpSubPath, httpPath},
+				expectedOutput: []options.Upstream{httpSubPath, httpPath},
 			}),
 			Entry("when a subpath is registered (out of order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{httpPath, httpSubPath},
-				expectedOutput: options.Upstreams{httpSubPath, httpPath},
+				input:          []options.Upstream{httpPath, httpSubPath},
+				expectedOutput: []options.Upstream{httpSubPath, httpPath},
 			}),
 			Entry("when longer paths are registered (in order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{longerPath, httpPath},
-				expectedOutput: options.Upstreams{longerPath, httpPath},
+				input:          []options.Upstream{longerPath, httpPath},
+				expectedOutput: []options.Upstream{longerPath, httpPath},
 			}),
 			Entry("when longer paths are registered (out of order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{httpPath, longerPath},
-				expectedOutput: options.Upstreams{longerPath, httpPath},
+				input:          []options.Upstream{httpPath, longerPath},
+				expectedOutput: []options.Upstream{longerPath, httpPath},
 			}),
 			Entry("when a rewrite target is registered (in order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{shortPathWithRewrite, longerPath},
-				expectedOutput: options.Upstreams{shortPathWithRewrite, longerPath},
+				input:          []options.Upstream{shortPathWithRewrite, longerPath},
+				expectedOutput: []options.Upstream{shortPathWithRewrite, longerPath},
 			}),
 			Entry("when a rewrite target is registered (out of order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{longerPath, shortPathWithRewrite},
-				expectedOutput: options.Upstreams{shortPathWithRewrite, longerPath},
+				input:          []options.Upstream{longerPath, shortPathWithRewrite},
+				expectedOutput: []options.Upstream{shortPathWithRewrite, longerPath},
 			}),
 			Entry("with multiple rewrite targets registered (in order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
-				expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
+				input:          []options.Upstream{shortSubPathWithRewrite, shortPathWithRewrite},
+				expectedOutput: []options.Upstream{shortSubPathWithRewrite, shortPathWithRewrite},
 			}),
 			Entry("with multiple rewrite targets registered (out of order)", sortByPathLongestTableInput{
-				input:          options.Upstreams{shortPathWithRewrite, shortSubPathWithRewrite},
-				expectedOutput: options.Upstreams{shortSubPathWithRewrite, shortPathWithRewrite},
+				input:          []options.Upstream{shortPathWithRewrite, shortSubPathWithRewrite},
+				expectedOutput: []options.Upstream{shortSubPathWithRewrite, shortPathWithRewrite},
 			}),
 		)
 	})
diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go
index 5f9e3716..03afb22a 100644
--- a/pkg/validation/options_test.go
+++ b/pkg/validation/options_test.go
@@ -22,7 +22,7 @@ const (
 
 func testOptions() *options.Options {
 	o := options.NewOptions()
-	o.UpstreamServers = append(o.UpstreamServers, options.Upstream{
+	o.UpstreamServers.Upstreams = append(o.UpstreamServers.Upstreams, options.Upstream{
 		ID:   "upstream",
 		Path: "/",
 		URI:  "http://127.0.0.1:8080/",
diff --git a/pkg/validation/upstreams_test.go b/pkg/validation/upstreams_test.go
index 122286ad..586c6d85 100644
--- a/pkg/validation/upstreams_test.go
+++ b/pkg/validation/upstreams_test.go
@@ -11,7 +11,7 @@ import (
 
 var _ = Describe("Upstreams", func() {
 	type validateUpstreamTableInput struct {
-		upstreams  options.Upstreams
+		upstreams  options.UpstreamConfig
 		errStrings []string
 	}
 
@@ -54,88 +54,104 @@ var _ = Describe("Upstreams", func() {
 			Expect(validateUpstreams(o.upstreams)).To(ConsistOf(o.errStrings))
 		},
 		Entry("with no upstreams", &validateUpstreamTableInput{
-			upstreams:  options.Upstreams{},
+			upstreams:  options.UpstreamConfig{},
 			errStrings: []string{},
 		}),
 		Entry("with valid upstreams", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				validHTTPUpstream,
-				validStaticUpstream,
-				validFileUpstream,
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					validHTTPUpstream,
+					validStaticUpstream,
+					validFileUpstream,
+				},
 			},
 			errStrings: []string{},
 		}),
 		Entry("with an empty ID", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "",
-					Path: "/foo",
-					URI:  "http://localhost:8080",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "",
+						Path: "/foo",
+						URI:  "http://localhost:8080",
+					},
 				},
 			},
 			errStrings: []string{emptyIDMsg},
 		}),
 		Entry("with an empty Path", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "",
-					URI:  "http://localhost:8080",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "",
+						URI:  "http://localhost:8080",
+					},
 				},
 			},
 			errStrings: []string{emptyPathMsg},
 		}),
 		Entry("with an empty Path", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "",
-					URI:  "http://localhost:8080",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "",
+						URI:  "http://localhost:8080",
+					},
 				},
 			},
 			errStrings: []string{emptyPathMsg},
 		}),
 		Entry("with an empty URI", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "/foo",
-					URI:  "",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "/foo",
+						URI:  "",
+					},
 				},
 			},
 			errStrings: []string{emptyURIMsg},
 		}),
 		Entry("with an invalid URI", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "/foo",
-					URI:  ":",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "/foo",
+						URI:  ":",
+					},
 				},
 			},
 			errStrings: []string{invalidURIMsg},
 		}),
 		Entry("with an invalid URI scheme", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "/foo",
-					URI:  "ftp://foo",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "/foo",
+						URI:  "ftp://foo",
+					},
 				},
 			},
 			errStrings: []string{invalidURISchemeMsg},
 		}),
 		Entry("with a static upstream and invalid optons", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:                    "foo",
-					Path:                  "/foo",
-					URI:                   "ftp://foo",
-					Static:                true,
-					FlushInterval:         &flushInterval,
-					PassHostHeader:        &truth,
-					ProxyWebSockets:       &truth,
-					InsecureSkipTLSVerify: true,
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:                    "foo",
+						Path:                  "/foo",
+						URI:                   "ftp://foo",
+						Static:                true,
+						FlushInterval:         &flushInterval,
+						PassHostHeader:        &truth,
+						ProxyWebSockets:       &truth,
+						InsecureSkipTLSVerify: true,
+					},
 				},
 			},
 			errStrings: []string{
@@ -147,41 +163,47 @@ var _ = Describe("Upstreams", func() {
 			},
 		}),
 		Entry("with duplicate IDs", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo",
-					Path: "/foo1",
-					URI:  "http://foo",
-				},
-				{
-					ID:   "foo",
-					Path: "/foo2",
-					URI:  "http://foo",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo",
+						Path: "/foo1",
+						URI:  "http://foo",
+					},
+					{
+						ID:   "foo",
+						Path: "/foo2",
+						URI:  "http://foo",
+					},
 				},
 			},
 			errStrings: []string{multipleIDsMsg},
 		}),
 		Entry("with duplicate Paths", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:   "foo1",
-					Path: "/foo",
-					URI:  "http://foo",
-				},
-				{
-					ID:   "foo2",
-					Path: "/foo",
-					URI:  "http://foo",
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:   "foo1",
+						Path: "/foo",
+						URI:  "http://foo",
+					},
+					{
+						ID:   "foo2",
+						Path: "/foo",
+						URI:  "http://foo",
+					},
 				},
 			},
 			errStrings: []string{multiplePathsMsg},
 		}),
 		Entry("when a static code is supplied without static", &validateUpstreamTableInput{
-			upstreams: options.Upstreams{
-				{
-					ID:         "foo",
-					Path:       "/foo",
-					StaticCode: &staticCode200,
+			upstreams: options.UpstreamConfig{
+				Upstreams: []options.Upstream{
+					{
+						ID:         "foo",
+						Path:       "/foo",
+						StaticCode: &staticCode200,
+					},
 				},
 			},
 			errStrings: []string{emptyURIMsg, staticCodeMsg},