Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 5c059ba

Browse files
fix: prevent open redirect in proxy authentication flow (#26647) (#27881)
Backport of #26647 Original PR: #26647 — fix: prevent open redirect in proxy authentication flow Merge commit: 7e7a6b4 Requested by: @jdomeracki-coder Clean cherry-pick, no conflicts. --- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers <[email protected]>
1 parent c15ae01 commit 5c059ba

3 files changed

Lines changed: 294 additions & 61 deletions

File tree

coderd/workspaceapps/proxy.go

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ func (s *Server) Attach(r chi.Router) {
165165
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
166166
}
167167

168+
// originLocalURL returns p as a relative URL rooted at the current origin,
169+
// safe to use as a redirect Location. p (typically r.URL.Path) is
170+
// attacker-controlled and already percent-decoded, so a leading "//" or "/\"
171+
// run would otherwise be parsed by http.Redirect or a browser as a
172+
// scheme-relative URL pointing at another host, turning a redirect back to the
173+
// same path into an open redirect. Collapsing the leading slash and backslash
174+
// run to a single "/" keeps the result same-origin, and url.URL.String()
175+
// percent-encodes any control characters in the path.
176+
func originLocalURL(p string) *url.URL {
177+
return &url.URL{Path: "/" + strings.TrimLeft(p, `/\`)}
178+
}
179+
168180
// handleAPIKeySmuggling is called by the proxy path and subdomain handlers to
169181
// process any "smuggled" API keys in the query parameters.
170182
//
@@ -265,19 +277,17 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
265277
HttpOnly: true,
266278
}))
267279

268-
// Strip the query parameter.
269-
path := r.URL.Path
270-
if path == "" {
271-
path = "/"
272-
}
280+
// Strip the smuggled API key query parameter and redirect back to the same
281+
// path. r.URL.Path is attacker-controlled and can smuggle a separate host
282+
// (e.g. "//evil.com"); originLocalURL keeps the redirect on the current
283+
// origin.
284+
redirectURL := originLocalURL(r.URL.Path)
285+
273286
q := r.URL.Query()
274287
q.Del(SubdomainProxyAPIKeyParam)
275-
rawQuery := q.Encode()
276-
if rawQuery != "" {
277-
path += "?" + q.Encode()
278-
}
288+
redirectURL.RawQuery = q.Encode()
279289

280-
http.Redirect(rw, r, path, http.StatusSeeOther)
290+
http.Redirect(rw, r, redirectURL.String(), http.StatusSeeOther)
281291
return false
282292
}
283293

@@ -615,7 +625,14 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT
615625
// Web applications typically request paths relative to the
616626
// root URL. This allows for routing behind a proxy or subpath.
617627
// See https://github.com/coder/code-server/issues/241 for examples.
618-
http.Redirect(rw, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
628+
//
629+
// r.URL.Path is attacker-controlled, so sanitize it before redirecting
630+
// to avoid an off-origin "//host" Location (see originLocalURL).
631+
redirectURL := originLocalURL(r.URL.Path)
632+
if !strings.HasSuffix(redirectURL.Path, "/") {
633+
redirectURL.Path += "/"
634+
}
635+
http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect)
619636
return
620637
}
621638
if path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" {
@@ -624,8 +641,13 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT
624641
// query parameters for server-side requests, but sometimes
625642
// client-side applications require the query parameters to render
626643
// properly. With code-server, this is the "folder" param.
627-
r.URL.RawQuery = appURL.RawQuery
628-
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
644+
//
645+
// r.URL.Path is attacker-controlled, so build the Location from a
646+
// sanitized same-origin path instead of r.URL directly (see
647+
// originLocalURL).
648+
redirectURL := originLocalURL(r.URL.Path)
649+
redirectURL.RawQuery = appURL.RawQuery
650+
http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect)
629651
return
630652
}
631653

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package workspaceapps
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// Test_originLocalURL checks that originLocalURL produces a redirect target that
12+
// stays on the current origin.
13+
func Test_originLocalURL(t *testing.T) {
14+
t.Parallel()
15+
16+
t.Run("RejectsOffOrigin", func(t *testing.T) {
17+
t.Parallel()
18+
19+
// Each path models an already-percent-decoded r.URL.Path that tries to
20+
// smuggle a separate host into the redirect.
21+
cases := []struct {
22+
name string
23+
path string
24+
}{
25+
{name: "DoubleSlash", path: "//evil.com/phish"},
26+
{name: "TripleSlash", path: "///evil.com/phish"},
27+
{name: "SlashBackslash", path: "/\\evil.com/phish"},
28+
{name: "SlashBackslashSlash", path: "/\\/evil.com/phish"},
29+
{name: "DoubleBackslash", path: "\\\\evil.com/phish"},
30+
{name: "SlashTab", path: "/\t/evil.com/phish"},
31+
{name: "SlashTabBackslash", path: "/\t\\evil.com/phish"},
32+
{name: "SlashNewline", path: "/\n/evil.com/phish"},
33+
{name: "SlashCarriageReturn", path: "/\r/evil.com/phish"},
34+
{name: "SlashTabDoubleSlash", path: "/\t//evil.com/phish"},
35+
}
36+
37+
for _, tc := range cases {
38+
t.Run(tc.name, func(t *testing.T) {
39+
t.Parallel()
40+
41+
loc := originLocalURL(tc.path).String()
42+
43+
// The Location must parse as a relative, same-origin reference.
44+
require.Falsef(t, strings.HasPrefix(loc, "//"),
45+
"path %q produced scheme-relative Location %q", tc.path, loc)
46+
parsed, err := url.Parse(loc)
47+
require.NoErrorf(t, err, "path %q produced unparseable Location %q", tc.path, loc)
48+
require.Emptyf(t, parsed.Scheme, "path %q produced Location %q with a scheme", tc.path, loc)
49+
require.Emptyf(t, parsed.Host, "path %q produced Location %q with a host", tc.path, loc)
50+
51+
// It must also be free of raw bytes a browser would normalize back
52+
// into an authority before resolving (a backslash becomes "/", and
53+
// tab/newline/CR are stripped, either of which could re-form
54+
// "//host"). url.URL.String() guarantees this by percent-encoding
55+
// them; we assert it here rather than reproducing browser
56+
// normalization in the code.
57+
for _, raw := range []string{`\`, "\t", "\n", "\r"} {
58+
require.NotContainsf(t, loc, raw,
59+
"path %q produced Location %q containing a raw %q", tc.path, loc, raw)
60+
}
61+
})
62+
}
63+
})
64+
65+
t.Run("EscapesControlCharacters", func(t *testing.T) {
66+
t.Parallel()
67+
68+
// A redirect built from a path containing a raw control character is an
69+
// open redirect: http.Redirect emits it verbatim (url.Parse rejects the
70+
// control byte and skips cleaning) and browsers strip tab/newline/CR
71+
// before resolving, re-forming "//evil.com". originLocalURL percent-encodes
72+
// each one. Assert every class is escaped so a future change that breaks
73+
// encoding for only one class is caught.
74+
cases := []struct {
75+
name string
76+
in string
77+
want string
78+
}{
79+
{name: "Tab", in: "/\t/evil.com", want: "/%09/evil.com"},
80+
{name: "Newline", in: "/\n/evil.com", want: "/%0A/evil.com"},
81+
{name: "CarriageReturn", in: "/\r/evil.com", want: "/%0D/evil.com"},
82+
}
83+
84+
for _, tc := range cases {
85+
t.Run(tc.name, func(t *testing.T) {
86+
t.Parallel()
87+
88+
require.Equalf(t, tc.want, originLocalURL(tc.in).String(),
89+
"originLocalURL(%q) must percent-encode the control character", tc.in)
90+
})
91+
}
92+
})
93+
94+
t.Run("PreservesLegitPaths", func(t *testing.T) {
95+
t.Parallel()
96+
97+
cases := []struct {
98+
name string
99+
in string
100+
want string
101+
}{
102+
{name: "Empty", in: "", want: "/"},
103+
{name: "Root", in: "/", want: "/"},
104+
{name: "Simple", in: "/test", want: "/test"},
105+
{name: "Nested", in: "/app/sub/page", want: "/app/sub/page"},
106+
{name: "PathApp", in: "/@user/ws/apps/app", want: "/@user/ws/apps/app"},
107+
}
108+
109+
for _, tc := range cases {
110+
t.Run(tc.name, func(t *testing.T) {
111+
t.Parallel()
112+
113+
require.Equalf(t, tc.want, originLocalURL(tc.in).String(), "originLocalURL(%q)", tc.in)
114+
})
115+
}
116+
})
117+
}

0 commit comments

Comments
 (0)