Skip to content

Commit 0150095

Browse files
authored
Refactor URL detection (#29960)
"Redirect" functions should only redirect if the target is for current Gitea site.
1 parent 0b4ff15 commit 0150095

File tree

9 files changed

+93
-40
lines changed

9 files changed

+93
-40
lines changed

modules/httplib/url.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,40 @@ import (
88
"strings"
99

1010
"code.gitea.io/gitea/modules/setting"
11+
"code.gitea.io/gitea/modules/util"
1112
)
1213

13-
// IsRiskyRedirectURL returns true if the URL is considered risky for redirects
14-
func IsRiskyRedirectURL(s string) bool {
14+
func urlIsRelative(s string, u *url.URL) bool {
1515
// Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
1616
// Therefore we should ignore these redirect locations to prevent open redirects
1717
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
18-
return true
18+
return false
1919
}
20+
return u != nil && u.Scheme == "" && u.Host == ""
21+
}
2022

23+
// IsRelativeURL detects if a URL is relative (no scheme or host)
24+
func IsRelativeURL(s string) bool {
2125
u, err := url.Parse(s)
22-
if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(s), strings.ToLower(setting.AppURL))) {
23-
return true
24-
}
26+
return err == nil && urlIsRelative(s, u)
27+
}
2528

26-
return false
29+
func IsCurrentGiteaSiteURL(s string) bool {
30+
u, err := url.Parse(s)
31+
if err != nil {
32+
return false
33+
}
34+
if u.Path != "" {
35+
u.Path = "/" + util.PathJoinRelX(u.Path)
36+
if !strings.HasSuffix(u.Path, "/") {
37+
u.Path += "/"
38+
}
39+
}
40+
if urlIsRelative(s, u) {
41+
return u.Path == "" || strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(setting.AppSubURL+"/"))
42+
}
43+
if u.Path == "" {
44+
u.Path = "/"
45+
}
46+
return strings.HasPrefix(strings.ToLower(u.String()), strings.ToLower(setting.AppURL))
2747
}

modules/httplib/url_test.go

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,65 @@ import (
77
"testing"
88

99
"code.gitea.io/gitea/modules/setting"
10+
"code.gitea.io/gitea/modules/test"
1011

1112
"github.com/stretchr/testify/assert"
1213
)
1314

14-
func TestIsRiskyRedirectURL(t *testing.T) {
15-
setting.AppURL = "http://localhost:3000/"
16-
tests := []struct {
17-
input string
18-
want bool
19-
}{
20-
{"", false},
21-
{"foo", false},
22-
{"/", false},
23-
{"/foo?k=%20#abc", false},
15+
func TestIsRelativeURL(t *testing.T) {
16+
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
17+
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
18+
rel := []string{
19+
"",
20+
"foo",
21+
"/",
22+
"/foo?k=%20#abc",
23+
}
24+
for _, s := range rel {
25+
assert.True(t, IsRelativeURL(s), "rel = %q", s)
26+
}
27+
abs := []string{
28+
"//",
29+
"\\\\",
30+
"/\\",
31+
"\\/",
32+
33+
"https://test.com",
34+
}
35+
for _, s := range abs {
36+
assert.False(t, IsRelativeURL(s), "abs = %q", s)
37+
}
38+
}
2439

25-
{"//", true},
26-
{"\\\\", true},
27-
{"/\\", true},
28-
{"\\/", true},
29-
{"mail:[email protected]", true},
30-
{"https://test.com", true},
31-
{setting.AppURL + "/foo", false},
32-
}
33-
for _, tt := range tests {
34-
t.Run(tt.input, func(t *testing.T) {
35-
assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input))
36-
})
40+
func TestIsCurrentGiteaSiteURL(t *testing.T) {
41+
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
42+
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
43+
good := []string{
44+
"?key=val",
45+
"/sub",
46+
"/sub/",
47+
"/sub/foo",
48+
"/sub/foo/",
49+
"http://localhost:3000/sub?key=val",
50+
"http://localhost:3000/sub/",
3751
}
52+
for _, s := range good {
53+
assert.True(t, IsCurrentGiteaSiteURL(s), "good = %q", s)
54+
}
55+
bad := []string{
56+
"/",
57+
"//",
58+
"\\\\",
59+
"/foo",
60+
"http://localhost:3000/sub/..",
61+
"http://localhost:3000/other",
62+
"http://other/",
63+
}
64+
for _, s := range bad {
65+
assert.False(t, IsCurrentGiteaSiteURL(s), "bad = %q", s)
66+
}
67+
68+
setting.AppURL = "http://localhost:3000/"
69+
setting.AppSubURL = ""
70+
assert.True(t, IsCurrentGiteaSiteURL("http://localhost:3000?key=val"))
3871
}

routers/common/redirect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
1717
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
1818
// then frontend needs this delegate to redirect to the new location with hash correctly.
1919
redirect := req.PostFormValue("redirect")
20-
if httplib.IsRiskyRedirectURL(redirect) {
20+
if !httplib.IsCurrentGiteaSiteURL(redirect) {
2121
resp.WriteHeader(http.StatusBadRequest)
2222
return
2323
}

routers/web/auth/auth.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func RedirectAfterLogin(ctx *context.Context) {
133133
if setting.LandingPageURL == setting.LandingPageLogin {
134134
nextRedirectTo = setting.AppSubURL + "/" // do not cycle-redirect to the login page
135135
}
136-
ctx.RedirectToFirst(redirectTo, nextRedirectTo)
136+
ctx.RedirectToCurrentSite(redirectTo, nextRedirectTo)
137137
}
138138

139139
func CheckAutoLogin(ctx *context.Context) bool {
@@ -371,7 +371,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
371371
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) {
372372
middleware.DeleteRedirectToCookie(ctx.Resp)
373373
if obeyRedirect {
374-
ctx.RedirectToFirst(redirectTo)
374+
ctx.RedirectToCurrentSite(redirectTo)
375375
}
376376
return redirectTo
377377
}
@@ -808,7 +808,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
808808
ctx.Flash.Success(ctx.Tr("auth.account_activated"))
809809
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
810810
middleware.DeleteRedirectToCookie(ctx.Resp)
811-
ctx.RedirectToFirst(redirectTo)
811+
ctx.RedirectToCurrentSite(redirectTo)
812812
return
813813
}
814814

routers/web/auth/oauth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1157,7 +1157,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
11571157

11581158
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
11591159
middleware.DeleteRedirectToCookie(ctx.Resp)
1160-
ctx.RedirectToFirst(redirectTo)
1160+
ctx.RedirectToCurrentSite(redirectTo)
11611161
return
11621162
}
11631163

routers/web/auth/password.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func MustChangePasswordPost(ctx *context.Context) {
314314

315315
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) {
316316
middleware.DeleteRedirectToCookie(ctx.Resp)
317-
ctx.RedirectToFirst(redirectTo)
317+
ctx.RedirectToCurrentSite(redirectTo)
318318
return
319319
}
320320

routers/web/repo/repo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ func Action(ctx *context.Context) {
371371
return
372372
}
373373

374-
ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.Repo.RepoLink)
374+
ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"), ctx.Repo.RepoLink)
375375
}
376376

377377
func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error {

routers/web/web.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
174174

175175
// Redirect to dashboard (or alternate location) if user tries to visit any non-login page.
176176
if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" {
177-
ctx.RedirectToFirst(ctx.FormString("redirect_to"))
177+
ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"))
178178
return
179179
}
180180

services/context/context_response.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ func RedirectToUser(ctx *Base, userName string, redirectUserID int64) {
4444
ctx.Redirect(path.Join(setting.AppSubURL, redirectPath), http.StatusTemporaryRedirect)
4545
}
4646

47-
// RedirectToFirst redirects to first not empty URL
48-
func (ctx *Context) RedirectToFirst(location ...string) {
47+
// RedirectToCurrentSite redirects to first not empty URL which belongs to current site
48+
func (ctx *Context) RedirectToCurrentSite(location ...string) {
4949
for _, loc := range location {
5050
if len(loc) == 0 {
5151
continue
5252
}
5353

54-
if httplib.IsRiskyRedirectURL(loc) {
54+
if !httplib.IsCurrentGiteaSiteURL(loc) {
5555
continue
5656
}
5757

0 commit comments

Comments
 (0)