Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion models/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions modules/templates/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package templates

import (
"html/template"
"net/url"
"strings"
"testing"

Expand Down Expand Up @@ -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))
}
7 changes: 7 additions & 0 deletions modules/util/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
2 changes: 1 addition & 1 deletion routers/web/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 10 additions & 9 deletions routers/web/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package auth

import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -67,29 +68,29 @@ 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))

// 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&#39;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))
Expand All @@ -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, "")
Expand Down
4 changes: 1 addition & 3 deletions routers/web/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
wxiaoguang marked this conversation as resolved.
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
Expand Down
8 changes: 4 additions & 4 deletions services/context/base_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
23 changes: 19 additions & 4 deletions services/context/context_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -143,10 +144,17 @@ func (ctx *Context) NotFound(logErr error) {
}

func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
// it's safe to show the error message to end users since the error is fully controlled by our error system
var errorMsg string
if logErr != nil {
log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr)
if !setting.IsProd {
ctx.Data["ErrorMsg"] = logErr
log.Log(2, log.DEBUG, "NotFound: %s: %v", logMsg, logErr)
errorMsg = logErr.Error()
}
if logMsg != "" {
if errorMsg == "" {
errorMsg = logMsg
} else {
errorMsg = logMsg + "; " + errorMsg
}
}

Expand All @@ -160,17 +168,24 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
}

if !showHTML {
ctx.plainTextInternal(3, http.StatusNotFound, []byte("Not found.\n"))
errorMsg = util.IfZero(errorMsg, "Not found.")
ctx.plainTextInternal(3, http.StatusNotFound, []byte(errorMsg+"\n"))
return
}

ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
ctx.Data["Title"] = "Page Not Found"
ctx.Data["ErrorMsg"] = errorMsg
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)
}

Expand Down
3 changes: 1 addition & 2 deletions templates/user/auth/external_auth_methods.tmpl
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<div id="external-login-navigator" class="tw-py-1 tw-flex tw-flex-col tw-gap-3">
{{range $provider := .OAuth2Providers}}
{{/* use QueryEscape for consistent with frontend urlQueryEscape, it is right for a path component */}}
<a class="ui button external-login-link tw-gap-3" data-require-appurl-check="true" rel="nofollow" href="{{AppSubUrl}}/user/oauth2/{{$provider.DisplayName | QueryEscape}}">
<a class="ui button external-login-link tw-gap-3" data-require-appurl-check="true" rel="nofollow" href="{{AppSubUrl}}/user/oauth2/{{$provider.DisplayName | PathEscape}}">
{{$provider.IconHTML 24}} {{ctx.Locale.Tr "sign_in_with_provider" $provider.DisplayName}}
</a>
{{end}}
Expand Down
2 changes: 1 addition & 1 deletion templates/user/settings/security/accountlinks.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div class="menu">
{{range $key := .OrderedOAuth2Names}}
{{$provider := index $.OAuth2Providers $key}}
<a class="item" href="{{AppSubUrl}}/user/oauth2/{{$key}}">
<a class="item" href="{{AppSubUrl}}/user/oauth2/{{$key | PathEscape}}">
{{$provider.IconHTML 20}}
{{$provider.DisplayName}}
</a>
Expand Down
40 changes: 31 additions & 9 deletions tests/integration/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"code.gitea.io/gitea/services/oauth2_provider"
"code.gitea.io/gitea/tests"

"github.com/PuerkitoBio/goquery"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/stretchr/testify/assert"
Expand All @@ -44,7 +45,7 @@ func TestOAuth2Provider(t *testing.T) {
t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect)

t.Run("OAuth2WellKnown", testOAuth2WellKnown)
t.Run("OAuthSourceWithSpace", testOAuthSourceWithSpace)
t.Run("OAuthSourceSpecialChars", testOAuthSourceSpecialChars)
// TODO: move more tests as sub-tests here, avoid unnecessary PrepareTestEnv
}

Expand Down Expand Up @@ -1098,19 +1099,40 @@ func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {

// Checks if an OAuth provider with spaces within the name does work,
// with the encoding of its names in the URL (PR#37327)
func testOAuthSourceWithSpace(t *testing.T) {
func testOAuthSourceSpecialChars(t *testing.T) {
mockServer := createMockServer()
defer mockServer.Close()

authName := "oauth test with spaces"
oauth2Source := oauth2.Source{
addOAuth2Source(t, "test space", oauth2.Source{
Provider: "openidConnect",
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
})
addOAuth2Source(t, "test+plus", oauth2.Source{
Provider: "openidConnect",
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
})

testOAuth2 := func(t *testing.T, uri string, statusCode int) {
req := NewRequest(t, "GET", uri)
resp := MakeRequest(t, req, statusCode)
assert.NotEmpty(t, resp.Header().Get("Location"))
Comment thread
wxiaoguang marked this conversation as resolved.
Outdated
}
addOAuth2Source(t, authName, oauth2Source)

session := emptyTestSession(t)
req := NewRequest(t, "GET", "/user/oauth2/"+url.QueryEscape(authName))
resp := session.MakeRequest(t, req, http.StatusTemporaryRedirect)
assert.Contains(t, resp.Header().Get("Location"), mockServer.URL+"/authorize")
req := MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusOK)
doc := NewHTMLParser(t, req.Body)
var oauth2Links []string
doc.Find(".external-login-link").Each(func(i int, s *goquery.Selection) {
oauth2Links = append(oauth2Links, s.AttrOr("href", ""))
})
assert.Equal(t, []string{
Comment thread
wxiaoguang marked this conversation as resolved.
Comment thread
wxiaoguang marked this conversation as resolved.
"/user/oauth2/test%20space",
"/user/oauth2/test+plus",
}, oauth2Links)

testOAuth2(t, "/user/oauth2/test%20space", http.StatusTemporaryRedirect)
testOAuth2(t, "/user/oauth2/test+space", http.StatusNotFound)

testOAuth2(t, "/user/oauth2/test+plus", http.StatusTemporaryRedirect)
testOAuth2(t, "/user/oauth2/test%2Bplus", http.StatusTemporaryRedirect)
testOAuth2(t, "/user/oauth2/test%20plus", http.StatusNotFound)
}
4 changes: 2 additions & 2 deletions web_src/js/features/admin/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {urlQueryEscape} from '../../utils.ts';
import {pathEscape} from '../../utils/url.ts';

const {appSubUrl} = window.config;

Expand Down Expand Up @@ -232,7 +232,7 @@ function initAdminAuthentication() {
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
const onAuthNameChange = function () {
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${urlQueryEscape(elAuthName.value)}/callback`;
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
Expand Down
7 changes: 0 additions & 7 deletions web_src/js/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref,
translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
urlQueryEscape,
} from './utils.ts';

test('dirname', () => {
Expand Down Expand Up @@ -34,12 +33,6 @@ test('stripTags', () => {
expect(stripTags('<a>test</a>')).toEqual('test');
});

test('urlQueryEscape', () => {
const input = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
const 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~';
expect(urlQueryEscape(input)).toEqual(expected);
});

test('parseIssueHref', () => {
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
Expand Down
9 changes: 0 additions & 9 deletions web_src/js/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,6 @@ export function stripTags(text: string): string {
return text;
}

export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

export function parseIssueHref(href: string): IssuePathInfo {
// FIXME: it should use pathname and trim the appSubUrl ahead
const path = (href || '').replace(/[#?].*$/, '');
Expand Down
22 changes: 18 additions & 4 deletions web_src/js/utils/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';
import {linkifyURLs, pathEscape, pathEscapeSegments, toOriginUrl, urlQueryEscape} from './url.ts';

test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
describe('escape', () => {
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
test('urlQueryEscape', () => {
const 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~';
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
});

test('pathEscape', () => {
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(pathEscape(queryNonAscii)).toEqual(expected);
});

test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
});
});

test('linkifyURLs', () => {
Expand Down
30 changes: 29 additions & 1 deletion web_src/js/utils/url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll('%20', '+');
}

export function pathEscape(s: string): string {
// See "TestPathEscape" in backend
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll(/%(\w\w)/g, (v) => {
switch (v) {
case '%24': return '$';
case '%26': return '&';
case '%2B': return '+';
case '%3A': return ':';
case '%3D': return '=';
case '%40': return '@';
default: return v;
}
});
}

export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
// The same as backend's PathEscapeSegments
return s.split('/').map(pathEscape).join('/');
}

// Match HTML tags (to skip) or URLs (to linkify) in HTML content
Expand Down
Loading