From 04066c0d5536102d083b7c34d9ce5009e71960fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Wed, 15 Jul 2026 18:19:09 +0000 Subject: [PATCH 1/7] fix(coderd/httpmw): harden oauth2 redirect url validation --- coderd/externalauth.go | 12 +----------- coderd/httpmw/oauth2.go | 20 ++++++++++++++++---- coderd/httpmw/oauth2_test.go | 31 +++++++++++++++++++++++++++++++ coderd/userauth.go | 4 ++-- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 29eb53e67971d..ae9e9a514e6f0 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "github.com/sqlc-dev/pqtype" "golang.org/x/sync/errgroup" @@ -331,7 +330,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } @@ -429,12 +428,3 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported), } } - -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil { - return "/" - } - - return uri.RequestURI() -} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 71b20a2f28e47..b6a35c69560c3 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -133,7 +133,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = uriFromURL(redirect) + redirect = URIFromURL(redirect) } // When dynamic redirect URIs are enabled, validate the request Host @@ -532,13 +532,25 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H } } -func uriFromURL(u string) string { +// URIFromURL reduces a redirect URL down to a safe, relative path plus query +// string local to this application. Any scheme and host are dropped, since +// preserving them would allow an open redirect to another site. Opaque URLs +// (e.g. "javascript:..." or "data:...") are rejected outright and collapse to +// "/", since their content isn't a hierarchical path we can safely reduce. +func URIFromURL(u string) string { uri, err := url.Parse(u) - if err != nil { + if err != nil || uri.Opaque != "" { return "/" } - return uri.RequestURI() + // A path with two or more leading slashes (e.g. "///evil.com") is + // interpreted by some browsers as protocol-relative, so collapse any + // leading slashes down to exactly one. + path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") + if uri.RawQuery != "" { + return path + "?" + uri.RawQuery + } + return path } // buildDynamicRedirectURI constructs the OIDC redirect_uri from the incoming diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index 6638194ab3acc..daeeeb86b3bee 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -369,3 +369,34 @@ func (p *exchangeAssertingProvider) Exchange(_ context.Context, _ string, opts . func (*exchangeAssertingProvider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource { return nil } + +func TestURIFromURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", "/"}, + {"simple path", "/foo/bar", "/foo/bar"}, + {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, + {"no leading slash", "foo/bar", "/foo/bar"}, + {"malformed", "http://[::1]:namedport", "/"}, + // Cure53 CDM-02-009: triple-slash open redirect. + {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, + {"protocol relative double slash", "//evil.example.com", "/"}, + {"absolute url with host", "http://evil.example.com/path", "/path"}, + {"absolute url with host and query", "https://evil.example.com/path?a=b", "/path?a=b"}, + // Cure53 CDM-02-009: javascript: scheme bypassing CSP. + {"javascript scheme", "javascript:alert(origin)", "/"}, + {"nested javascript scheme", "javascript:javascript:javascript:alert(origin)", "/"}, + {"data scheme", "data:text/html,", "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, httpmw.URIFromURL(tt.in)) + }) + } +} diff --git a/coderd/userauth.go b/coderd/userauth.go index 4babef1be8f24..dff06c04d4339 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1140,7 +1140,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1574,7 +1574,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From ccd7573970064592470ea9f1e6541431bb115e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Wed, 15 Jul 2026 22:07:01 +0000 Subject: [PATCH 2/7] refactor(coderd/httpmw): keep redirect helper unexported, test internally --- coderd/externalauth.go | 25 ++++++++++++++++++- coderd/httpmw/oauth2.go | 6 ++--- coderd/httpmw/oauth2_internal_test.go | 36 +++++++++++++++++++++++++++ coderd/httpmw/oauth2_test.go | 31 ----------------------- coderd/userauth.go | 4 +-- 5 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 coderd/httpmw/oauth2_internal_test.go diff --git a/coderd/externalauth.go b/coderd/externalauth.go index ae9e9a514e6f0..d3d51c68fe227 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/http" + "net/url" + "strings" "github.com/sqlc-dev/pqtype" "golang.org/x/sync/errgroup" @@ -330,7 +332,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = httpmw.URIFromURL(redirect) + redirect = uriFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } @@ -428,3 +430,24 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported), } } + +// uriFromURL reduces a redirect URL down to a safe, relative path plus query +// string local to this application. Any scheme and host are dropped, since +// preserving them would allow an open redirect to another site. Opaque URLs +// (e.g. "javascript:..." or "data:...") are rejected outright and collapse to +// "/", since their content isn't a hierarchical path we can safely reduce. +func uriFromURL(u string) string { + uri, err := url.Parse(u) + if err != nil || uri.Opaque != "" { + return "/" + } + + // A path with two or more leading slashes (e.g. "///evil.com") is + // interpreted by some browsers as protocol-relative, so collapse any + // leading slashes down to exactly one. + path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") + if uri.RawQuery != "" { + return path + "?" + uri.RawQuery + } + return path +} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index b6a35c69560c3..70dfd3d4e15cd 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -133,7 +133,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = URIFromURL(redirect) + redirect = uriFromURL(redirect) } // When dynamic redirect URIs are enabled, validate the request Host @@ -532,12 +532,12 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H } } -// URIFromURL reduces a redirect URL down to a safe, relative path plus query +// uriFromURL reduces a redirect URL down to a safe, relative path plus query // string local to this application. Any scheme and host are dropped, since // preserving them would allow an open redirect to another site. Opaque URLs // (e.g. "javascript:..." or "data:...") are rejected outright and collapse to // "/", since their content isn't a hierarchical path we can safely reduce. -func URIFromURL(u string) string { +func uriFromURL(u string) string { uri, err := url.Parse(u) if err != nil || uri.Opaque != "" { return "/" diff --git a/coderd/httpmw/oauth2_internal_test.go b/coderd/httpmw/oauth2_internal_test.go new file mode 100644 index 0000000000000..dad0171635c56 --- /dev/null +++ b/coderd/httpmw/oauth2_internal_test.go @@ -0,0 +1,36 @@ +package httpmw + +import "testing" + +func TestUriFromURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", "/"}, + {"simple path", "/foo/bar", "/foo/bar"}, + {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, + {"no leading slash", "foo/bar", "/foo/bar"}, + {"malformed", "http://[::1]:namedport", "/"}, + // Cure53 CDM-02-009: triple-slash open redirect. + {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, + {"protocol relative double slash", "//evil.example.com", "/"}, + {"absolute url with host", "http://evil.example.com/path", "/path"}, + {"absolute url with host and query", "https://evil.example.com/path?a=b", "/path?a=b"}, + // Cure53 CDM-02-009: javascript: scheme bypassing CSP. + {"javascript scheme", "javascript:alert(origin)", "/"}, + {"nested javascript scheme", "javascript:javascript:javascript:alert(origin)", "/"}, + {"data scheme", "data:text/html,", "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := uriFromURL(tt.in); got != tt.want { + t.Errorf("uriFromURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index daeeeb86b3bee..6638194ab3acc 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -369,34 +369,3 @@ func (p *exchangeAssertingProvider) Exchange(_ context.Context, _ string, opts . func (*exchangeAssertingProvider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource { return nil } - -func TestURIFromURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in string - want string - }{ - {"empty", "", "/"}, - {"simple path", "/foo/bar", "/foo/bar"}, - {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, - {"no leading slash", "foo/bar", "/foo/bar"}, - {"malformed", "http://[::1]:namedport", "/"}, - // Cure53 CDM-02-009: triple-slash open redirect. - {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, - {"protocol relative double slash", "//evil.example.com", "/"}, - {"absolute url with host", "http://evil.example.com/path", "/path"}, - {"absolute url with host and query", "https://evil.example.com/path?a=b", "/path?a=b"}, - // Cure53 CDM-02-009: javascript: scheme bypassing CSP. - {"javascript scheme", "javascript:alert(origin)", "/"}, - {"nested javascript scheme", "javascript:javascript:javascript:alert(origin)", "/"}, - {"data scheme", "data:text/html,", "/"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tt.want, httpmw.URIFromURL(tt.in)) - }) - } -} diff --git a/coderd/userauth.go b/coderd/userauth.go index dff06c04d4339..4babef1be8f24 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1140,7 +1140,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = httpmw.URIFromURL(redirect) + redirect = uriFromURL(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1574,7 +1574,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = httpmw.URIFromURL(redirect) + redirect = uriFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From 16d7808c37a072a6326b6287df40c03007f30a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Tue, 21 Jul 2026 23:16:11 +0000 Subject: [PATCH 3/7] refactor(coderd): consolidate redirect url validation into one helper --- coderd/externalauth.go | 25 +------------------------ coderd/httpmw/oauth2.go | 6 +++--- coderd/httpmw/oauth2_internal_test.go | 6 +++--- coderd/userauth.go | 4 ++-- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/coderd/externalauth.go b/coderd/externalauth.go index d3d51c68fe227..ae9e9a514e6f0 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" - "strings" "github.com/sqlc-dev/pqtype" "golang.org/x/sync/errgroup" @@ -332,7 +330,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } @@ -430,24 +428,3 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported), } } - -// uriFromURL reduces a redirect URL down to a safe, relative path plus query -// string local to this application. Any scheme and host are dropped, since -// preserving them would allow an open redirect to another site. Opaque URLs -// (e.g. "javascript:..." or "data:...") are rejected outright and collapse to -// "/", since their content isn't a hierarchical path we can safely reduce. -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil || uri.Opaque != "" { - return "/" - } - - // A path with two or more leading slashes (e.g. "///evil.com") is - // interpreted by some browsers as protocol-relative, so collapse any - // leading slashes down to exactly one. - path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") - if uri.RawQuery != "" { - return path + "?" + uri.RawQuery - } - return path -} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 70dfd3d4e15cd..b6a35c69560c3 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -133,7 +133,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = uriFromURL(redirect) + redirect = URIFromURL(redirect) } // When dynamic redirect URIs are enabled, validate the request Host @@ -532,12 +532,12 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H } } -// uriFromURL reduces a redirect URL down to a safe, relative path plus query +// URIFromURL reduces a redirect URL down to a safe, relative path plus query // string local to this application. Any scheme and host are dropped, since // preserving them would allow an open redirect to another site. Opaque URLs // (e.g. "javascript:..." or "data:...") are rejected outright and collapse to // "/", since their content isn't a hierarchical path we can safely reduce. -func uriFromURL(u string) string { +func URIFromURL(u string) string { uri, err := url.Parse(u) if err != nil || uri.Opaque != "" { return "/" diff --git a/coderd/httpmw/oauth2_internal_test.go b/coderd/httpmw/oauth2_internal_test.go index dad0171635c56..e5ea6829dd39d 100644 --- a/coderd/httpmw/oauth2_internal_test.go +++ b/coderd/httpmw/oauth2_internal_test.go @@ -2,7 +2,7 @@ package httpmw import "testing" -func TestUriFromURL(t *testing.T) { +func TestURIFromURL(t *testing.T) { t.Parallel() tests := []struct { @@ -28,8 +28,8 @@ func TestUriFromURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := uriFromURL(tt.in); got != tt.want { - t.Errorf("uriFromURL(%q) = %q, want %q", tt.in, got, tt.want) + if got := URIFromURL(tt.in); got != tt.want { + t.Errorf("URIFromURL(%q) = %q, want %q", tt.in, got, tt.want) } }) } diff --git a/coderd/userauth.go b/coderd/userauth.go index 4babef1be8f24..dff06c04d4339 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1140,7 +1140,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1574,7 +1574,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = uriFromURL(redirect) + redirect = httpmw.URIFromURL(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From f546689eb49950c145734ee7ace5d565c30e8dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Tue, 21 Jul 2026 23:20:05 +0000 Subject: [PATCH 4/7] refactor(coderd/httpapi): move redirect sanitizer out of httpmw --- coderd/externalauth.go | 2 +- coderd/httpapi/redirect.go | 28 +++++++++++++++++++ .../redirect_test.go} | 14 ++++++---- coderd/httpmw/oauth2.go | 23 +-------------- coderd/userauth.go | 4 +-- 5 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 coderd/httpapi/redirect.go rename coderd/{httpmw/oauth2_internal_test.go => httpapi/redirect_test.go} (79%) diff --git a/coderd/externalauth.go b/coderd/externalauth.go index ae9e9a514e6f0..51b7727c00d69 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -330,7 +330,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = httpmw.URIFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go new file mode 100644 index 0000000000000..677bd8197541d --- /dev/null +++ b/coderd/httpapi/redirect.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "net/url" + "strings" +) + +// SafeRedirectPath reduces a redirect URL down to a safe, relative path plus +// query string local to this application. Any scheme and host are dropped, +// since preserving them would allow an open redirect to another site. Opaque +// URLs (e.g. "javascript:..." or "data:...") are rejected outright and +// collapse to "/", since their content isn't a hierarchical path we can +// safely reduce. +func SafeRedirectPath(u string) string { + uri, err := url.Parse(u) + if err != nil || uri.Opaque != "" { + return "/" + } + + // A path with two or more leading slashes (e.g. "///evil.com") is + // interpreted by some browsers as protocol-relative, so collapse any + // leading slashes down to exactly one. + path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") + if uri.RawQuery != "" { + return path + "?" + uri.RawQuery + } + return path +} diff --git a/coderd/httpmw/oauth2_internal_test.go b/coderd/httpapi/redirect_test.go similarity index 79% rename from coderd/httpmw/oauth2_internal_test.go rename to coderd/httpapi/redirect_test.go index e5ea6829dd39d..42429c0f97019 100644 --- a/coderd/httpmw/oauth2_internal_test.go +++ b/coderd/httpapi/redirect_test.go @@ -1,8 +1,12 @@ -package httpmw +package httpapi_test -import "testing" +import ( + "testing" -func TestURIFromURL(t *testing.T) { + "github.com/coder/coder/v2/coderd/httpapi" +) + +func TestSafeRedirectPath(t *testing.T) { t.Parallel() tests := []struct { @@ -28,8 +32,8 @@ func TestURIFromURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := URIFromURL(tt.in); got != tt.want { - t.Errorf("URIFromURL(%q) = %q, want %q", tt.in, got, tt.want) + if got := httpapi.SafeRedirectPath(tt.in); got != tt.want { + t.Errorf("SafeRedirectPath(%q) = %q, want %q", tt.in, got, tt.want) } }) } diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index b6a35c69560c3..fccc89642c000 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -133,7 +133,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = URIFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) } // When dynamic redirect URIs are enabled, validate the request Host @@ -532,27 +532,6 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H } } -// URIFromURL reduces a redirect URL down to a safe, relative path plus query -// string local to this application. Any scheme and host are dropped, since -// preserving them would allow an open redirect to another site. Opaque URLs -// (e.g. "javascript:..." or "data:...") are rejected outright and collapse to -// "/", since their content isn't a hierarchical path we can safely reduce. -func URIFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil || uri.Opaque != "" { - return "/" - } - - // A path with two or more leading slashes (e.g. "///evil.com") is - // interpreted by some browsers as protocol-relative, so collapse any - // leading slashes down to exactly one. - path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") - if uri.RawQuery != "" { - return path + "?" + uri.RawQuery - } - return path -} - // buildDynamicRedirectURI constructs the OIDC redirect_uri from the incoming // request, used when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is configured. // diff --git a/coderd/userauth.go b/coderd/userauth.go index dff06c04d4339..aae2854373e96 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1140,7 +1140,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = httpmw.URIFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1574,7 +1574,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = httpmw.URIFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From fc23006f4bf03376a9167868ce80b914b6c674f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Wed, 22 Jul 2026 14:31:10 -0600 Subject: [PATCH 5/7] Update coderd/httpapi/redirect.go --- coderd/httpapi/redirect.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go index 677bd8197541d..1f3ba2eaec0a9 100644 --- a/coderd/httpapi/redirect.go +++ b/coderd/httpapi/redirect.go @@ -5,12 +5,9 @@ import ( "strings" ) -// SafeRedirectPath reduces a redirect URL down to a safe, relative path plus -// query string local to this application. Any scheme and host are dropped, -// since preserving them would allow an open redirect to another site. Opaque -// URLs (e.g. "javascript:..." or "data:...") are rejected outright and -// collapse to "/", since their content isn't a hierarchical path we can -// safely reduce. +// SafeRedirectPath reduces a redirect URL down to a safe, relative path. The +// scheme and host are dropped to prevent redirecting to another origin. Opaque +// URLs (e.g. `javascript:`, `data:`) are rejected outright and default to /. func SafeRedirectPath(u string) string { uri, err := url.Parse(u) if err != nil || uri.Opaque != "" { From 5b5d122b0cda5dee84710e4551347e8ed8ec1377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Wed, 22 Jul 2026 14:31:17 -0600 Subject: [PATCH 6/7] Update coderd/httpapi/redirect.go --- coderd/httpapi/redirect.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go index 1f3ba2eaec0a9..f7f611f8384d9 100644 --- a/coderd/httpapi/redirect.go +++ b/coderd/httpapi/redirect.go @@ -14,9 +14,8 @@ func SafeRedirectPath(u string) string { return "/" } - // A path with two or more leading slashes (e.g. "///evil.com") is - // interpreted by some browsers as protocol-relative, so collapse any - // leading slashes down to exactly one. + // A path with 2 or more leading slashes (e.g. "//evil.com") is interpreted as + // protocol-relative, so make sure there is exactly one. path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") if uri.RawQuery != "" { return path + "?" + uri.RawQuery From 38d9a6aa39fd56c08031cd3358b19613a11bd76f Mon Sep 17 00:00:00 2001 From: McKayla Washburn-Love Date: Thu, 23 Jul 2026 17:36:36 +0000 Subject: [PATCH 7/7] feedback --- coderd/httpapi/redirect.go | 9 ++++++++- coderd/httpapi/redirect_test.go | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go index f7f611f8384d9..6c1c49e39e389 100644 --- a/coderd/httpapi/redirect.go +++ b/coderd/httpapi/redirect.go @@ -18,7 +18,14 @@ func SafeRedirectPath(u string) string { // protocol-relative, so make sure there is exactly one. path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") if uri.RawQuery != "" { - return path + "?" + uri.RawQuery + path += "?" + uri.RawQuery + } + // We're specifically checking Fragment instead of RawFragment here because + // RawFragment is only populated when the parser needs to preserve a + // non-default escaping, so it is empty for plain-alphanumeric fragments like + // "#wooble". EscapedFragment handles escaping correctly in either case. + if uri.Fragment != "" { + path += "#" + uri.EscapedFragment() } return path } diff --git a/coderd/httpapi/redirect_test.go b/coderd/httpapi/redirect_test.go index 42429c0f97019..7bb9b88d99c89 100644 --- a/coderd/httpapi/redirect_test.go +++ b/coderd/httpapi/redirect_test.go @@ -17,8 +17,16 @@ func TestSafeRedirectPath(t *testing.T) { {"empty", "", "/"}, {"simple path", "/foo/bar", "/foo/bar"}, {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, + {"path with fragment", "/foo/bar#wooble", "/foo/bar#wooble"}, + {"path with query+fragment", "/foo/bar?wibble=wobble#wooble", "/foo/bar?wibble=wobble#wooble"}, {"no leading slash", "foo/bar", "/foo/bar"}, {"malformed", "http://[::1]:namedport", "/"}, + // Ensure backslashes aren't a blindspot. + {"backslash after slash", `/\evil.example.com`, "/%5Cevil.example.com"}, + {"leading double backslash", `\\evil.example.com`, "/%5C%5Cevil.example.com"}, + {"backslash then slash", `\/evil.example.com`, "/%5C/evil.example.com"}, + {"mixed slash backslash", `/\/evil.example.com`, "/%5C/evil.example.com"}, + {"scheme with backslash", `https:/\evil.example.com`, "/%5Cevil.example.com"}, // Cure53 CDM-02-009: triple-slash open redirect. {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, {"protocol relative double slash", "//evil.example.com", "/"},