Skip to content

Commit b16e209

Browse files
committed
fix: avoid unescaping slashes when proxying URLs
Doing all the path operations on URL.Path means that they're being performed on an unescaped URL. Unfortunately, this URL is *not* guaranteed to be the directly unescaped version of URL.EscapedPath(); in particular, slashes will be unescaped in URL.Path, e.g. given: /abc%2Fdef URL.Path will be: /abc/def In these cases, URL.RawPath is also set with the escaped version of the path. However, once URL.Path is modified, URL.RawPath is ignored, and URL.EscapedPath() returns the path-escaped version of URL.Path...which, in this case, is now /abc/def, not the correct /abc%2Fdef. As a result, when performing any path modifications during proxying (i.e. proxying to an upstream URL with a path component, or applying StripPath), this results in any *escaped* slashes in the proxied URL being unescaped. In order to work around this, rewriting operations need to take place on the *escaped* path, not the unescaped one, and then an intermediate URL can be used to determine the Path and RawPath values to set on the forward URL. This incurs a small breaking change in that StripPath is now applied to the *escaped* URL, not the unescaped URL. These semantics arguably make more sense, since StripPath otherwise also cannot distinguish between unencoded slashes within a path segment vs those that are separating path segments, but it's still a breaking change nonetheless. Signed-off-by: Ryan Gonzalez <[email protected]>
1 parent 817943a commit b16e209

File tree

2 files changed

+39
-7
lines changed

2 files changed

+39
-7
lines changed

proxy/proxy.go

+33-7
Original file line numberDiff line numberDiff line change
@@ -184,20 +184,46 @@ func ConfigureBackendURL(r *http.Request, rl *rule.Rule) error {
184184
}
185185

186186
proxyHost := r.Host
187-
proxyPath := r.URL.Path
187+
proxyPath := r.URL.EscapedPath()
188188

189189
backendHost := p.Host
190-
backendPath := p.Path
190+
backendPath := p.EscapedPath()
191191
backendScheme := p.Scheme
192192

193+
// We need to build a new path from the incoming *escaped* path components,
194+
// because just relying on the already-escaped URL.Path results in escaped
195+
// slashes (%2F) being irreversibly converted to actual slashes.
196+
//
197+
// However, once the new escaped path is built, we can't immediately assign it
198+
// to the resulting URL:
199+
// - Assigning to URL.Path would mean assigning an escaped path to
200+
// the unescaped path attribute, which would end up double-escaping
201+
// everything.
202+
// - Setting RawPath on its own will result in its value being ignored,
203+
// because URL.EscapedPath() (used by URL.String()) will ignore RawPath
204+
// if it does not match an escaped version of Path.
205+
// - Directly escaping the path ourselves first to assign it to Path isn't
206+
// possible, because net/url exposes *no* way to escape an entire path,
207+
// only path segments.
208+
//
209+
// In order to be able to set both URL.Path and URL.RawPath, we re-parse the
210+
// escaped path as an intermediate URL. This gives us the correct Path and
211+
// RawPath values that can then be set on the forward URL.
212+
newEscapedPath := "/" + strings.TrimLeft("/"+strings.Trim(backendPath, "/")+"/"+strings.TrimLeft(proxyPath, "/"), "/")
213+
if rl.Upstream.StripPath != "" {
214+
newEscapedPath = strings.Replace(newEscapedPath, "/"+strings.Trim(rl.Upstream.StripPath, "/"), "", 1)
215+
}
216+
217+
intermediatePathURL, err := url.Parse(newEscapedPath)
218+
if err != nil {
219+
return errors.WithStack(err)
220+
}
221+
193222
forwardURL := r.URL
194223
forwardURL.Scheme = backendScheme
195224
forwardURL.Host = backendHost
196-
forwardURL.Path = "/" + strings.TrimLeft("/"+strings.Trim(backendPath, "/")+"/"+strings.TrimLeft(proxyPath, "/"), "/")
197-
198-
if rl.Upstream.StripPath != "" {
199-
forwardURL.Path = strings.Replace(forwardURL.Path, "/"+strings.Trim(rl.Upstream.StripPath, "/"), "", 1)
200-
}
225+
forwardURL.RawPath = intermediatePathURL.RawPath
226+
forwardURL.Path = intermediatePathURL.Path
201227

202228
r.Host = backendHost
203229
if rl.Upstream.PreserveHost {

proxy/proxy_test.go

+6
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,12 @@ func TestConfigureBackendURL(t *testing.T) {
501501
eURL: "http://localhost:4000/foo/users/1234",
502502
eHost: "localhost:3000",
503503
},
504+
{
505+
r: &http.Request{Host: "localhost:3000", URL: &url.URL{RawPath: "/api/users/12%2F34", Path: "/api/users/12/34", Scheme: "http"}},
506+
rl: &rule.Rule{Upstream: rule.Upstream{URL: "http://localhost:4000/foo/", PreserveHost: true, StripPath: "api"}},
507+
eURL: "http://localhost:4000/foo/users/12%2F34",
508+
eHost: "localhost:3000",
509+
},
504510
} {
505511
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
506512
require.NoError(t, proxy.ConfigureBackendURL(tc.r, tc.rl))

0 commit comments

Comments
 (0)