diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index 846c386a20c..0bc2b517111 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -627,7 +627,7 @@ func GetActiveOAuth2SourceByAuthName(ctx context.Context, name string) (*Source, } if !has { - return nil, fmt.Errorf("oauth2 source not found, name: %q", name) + return nil, util.NewNotExistErrorf("oauth2 source not found, name: %q", name) } return authSource, nil diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go index cf1db324768..c21c20efff9 100644 --- a/modules/templates/helper_test.go +++ b/modules/templates/helper_test.go @@ -5,6 +5,7 @@ package templates import ( "html/template" + "net/url" "strings" "testing" @@ -169,9 +170,21 @@ func TestQueryBuild(t *testing.T) { }) } +const queryNonASCII = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars + func TestQueryEscape(t *testing.T) { // this test is a reference for "urlQueryEscape" in JS - in := "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars - expected := "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~" - assert.Equal(t, expected, string(queryEscape(in))) + // Special case for space encoding: + // * RFC 3986: Uniform Resource Identifier (URI): %20 + // * WHATWG HTML: application/x-www-form-urlencoded: + + // * JavaScript: encodeURIComponent() uses "%20". URLSearchParams uses "+" + // * Golang: QueryEscape uses "+" + expected := "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~" + assert.Equal(t, expected, url.QueryEscape(queryNonASCII)) +} + +func TestPathEscape(t *testing.T) { + // this test is a reference for "pathEscape" in JS + expected := "%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~" + assert.Equal(t, expected, url.PathEscape(queryNonASCII)) } diff --git a/modules/util/util_test.go b/modules/util/util_test.go index ec8b738e543..9decf90f676 100644 --- a/modules/util/util_test.go +++ b/modules/util/util_test.go @@ -184,3 +184,10 @@ func TestOptionalArg(t *testing.T) { assert.Equal(t, 42, bar(nil)) assert.Equal(t, 100, bar(nil, 100)) } + +func TestPathEscapeSegments(t *testing.T) { + assert.Equal(t, "a", PathEscapeSegments("a")) + assert.Equal(t, "a/b", PathEscapeSegments("a/b")) + assert.Equal(t, "a/b%20c", PathEscapeSegments("a/b c")) + assert.Equal(t, "a/b+c", PathEscapeSegments("a/b+c")) +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index eb2045781dd..1baa0225217 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -230,7 +230,7 @@ func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool return false } - skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.QueryEscape(data.oauth2Providers[0].DisplayName()) + skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName()) if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" { skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo) } diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go index 943085a9635..a06e209e4f4 100644 --- a/routers/web/auth/auth_test.go +++ b/routers/web/auth/auth_test.go @@ -4,6 +4,7 @@ package auth import ( + "html/template" "net/http" "net/http/httptest" "net/url" @@ -67,15 +68,15 @@ func TestWebAuthOAuth2(t *testing.T) { defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)() _ = oauth2.Init(t.Context()) - addOAuth2Source(t, "dummy-auth-source", oauth2.Source{}) + addOAuth2Source(t, "dummy+auth's source", oauth2.Source{}) t.Run("OAuth2MissingField", func(t *testing.T) { defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) { - return goth.User{Provider: "dummy-auth-source", UserID: "dummy-user"}, nil + return goth.User{Provider: "dummy+auth's source", UserID: "dummy-user"}, nil })() mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")} - ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback?code=dummy-code", mockOpt) - ctx.SetPathParam("provider", "dummy-auth-source") + ctx, resp := contexttest.MockContext(t, "/user/oauth2/..../callback?code=dummy-code", mockOpt) + ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source") SignInOAuthCallback(ctx) assert.Equal(t, http.StatusSeeOther, resp.Code) assert.Equal(t, "/user/link_account", test.RedirectURL(resp)) @@ -83,13 +84,13 @@ func TestWebAuthOAuth2(t *testing.T) { // then the user will be redirected to the link account page, and see a message about the missing fields ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt) LinkAccount(ctx) - assert.EqualValues(t, "auth.oauth_callback_unable_auto_reg:dummy-auth-source,email", ctx.Data["AutoRegistrationFailedPrompt"]) + assert.Equal(t, template.HTML("auth.oauth_callback_unable_auto_reg:dummy+auth's source,email"), ctx.Data["AutoRegistrationFailedPrompt"]) }) t.Run("OAuth2CallbackError", func(t *testing.T) { mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")} - ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback", mockOpt) - ctx.SetPathParam("provider", "dummy-auth-source") + ctx, resp := contexttest.MockContext(t, "/user/oauth2/...../callback", mockOpt) + ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source") SignInOAuthCallback(ctx) assert.Equal(t, http.StatusSeeOther, resp.Code) assert.Equal(t, "/user/login", test.RedirectURL(resp)) @@ -112,8 +113,8 @@ func TestWebAuthOAuth2(t *testing.T) { assert.Equal(t, expectedRedirect, test.RedirectURL(resp)) } } - testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy-auth-source") - testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy-auth-source?redirect_to=%2F") + testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source") + testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source?redirect_to=%2F") *enablePassword, *enableOpenID, *enablePasskey = true, false, false testSignIn(t, "/user/login", http.StatusOK, "") diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index e3f7e382424..8645aedbdee 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -36,9 +36,7 @@ import ( // SignInOAuth handles the OAuth2 login buttons func SignInOAuth(ctx *context.Context) { - // the provider is escaped by backend QueryEscape and frontend urlQueryEscape - // so always use QueryUnescape to decode it - authName, _ := url.QueryUnescape(ctx.PathParamRaw("provider")) + authName := ctx.PathParam("provider") authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName) if err != nil { ctx.ServerError("SignIn", err) diff --git a/services/context/base_path.go b/services/context/base_path.go index 63e60c8654e..8c353f7acaf 100644 --- a/services/context/base_path.go +++ b/services/context/base_path.go @@ -44,9 +44,9 @@ func (b *Base) PathParamInt(p string) int { // SetPathParam set request path params into routes func (b *Base) SetPathParam(name, value string) { - if strings.HasPrefix(name, ":") { - setting.PanicInDevOrTesting("path param should not start with ':'") - name = name[1:] - } chi.RouteContext(b).URLParams.Add(name, url.PathEscape(value)) } + +func (b *Base) SetPathParamRaw(name, value string) { + chi.RouteContext(b).URLParams.Add(name, value) +} diff --git a/services/context/context_response.go b/services/context/context_response.go index d057f8d41eb..1fd7a0b447f 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" ) @@ -143,11 +144,9 @@ func (ctx *Context) NotFound(logErr error) { } func (ctx *Context) notFoundInternal(logMsg string, logErr error) { + // TODO: it's safe to show the error message to end users if the error is fully controlled by our error system if logErr != nil { log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr) - if !setting.IsProd { - ctx.Data["ErrorMsg"] = logErr - } } // response simple message if Accept isn't text/html @@ -166,11 +165,17 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) { ctx.Data["IsRepo"] = ctx.Repo.Repository != nil ctx.Data["Title"] = "Page Not Found" + ctx.Data["ErrorMsg"] = "" // FIXME: the template never renders this message, need to fix in the future (and show safe messages to end users) ctx.HTML(http.StatusNotFound, "status/404") } // ServerError displays a 500 (Internal Server Error) page and prints the given error, if any. +// If the error is controlled by our error system, a related 404 page can be displayed instead. func (ctx *Context) ServerError(logMsg string, logErr error) { + if errors.Is(logErr, util.ErrNotExist) { + ctx.notFoundInternal(logMsg, logErr) + return + } ctx.serverErrorInternal(logMsg, logErr) } diff --git a/templates/user/auth/external_auth_methods.tmpl b/templates/user/auth/external_auth_methods.tmpl index c23cab65657..119be95d8bb 100644 --- a/templates/user/auth/external_auth_methods.tmpl +++ b/templates/user/auth/external_auth_methods.tmpl @@ -1,7 +1,6 @@
{{range $provider := .OAuth2Providers}} - {{/* use QueryEscape for consistent with frontend urlQueryEscape, it is right for a path component */}} - + {{$provider.IconHTML 24}} {{ctx.Locale.Tr "sign_in_with_provider" $provider.DisplayName}} {{end}} diff --git a/templates/user/settings/security/accountlinks.tmpl b/templates/user/settings/security/accountlinks.tmpl index 89228e1ae80..2fb1784b415 100644 --- a/templates/user/settings/security/accountlinks.tmpl +++ b/templates/user/settings/security/accountlinks.tmpl @@ -9,7 +9,7 @@