Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use fetch to send requests to create issues/comments #25258

Merged
merged 3 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions modules/context/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ func (b *Base) JSONRedirect(redirect string) {
b.JSON(http.StatusOK, map[string]any{"redirect": redirect})
}

func (b *Base) JSONError(msg string) {
b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg})
}

// RemoteAddr returns the client machine ip address
func (b *Base) RemoteAddr() string {
return b.Req.RemoteAddr
Expand Down
10 changes: 2 additions & 8 deletions modules/context/context_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
Expand Down Expand Up @@ -49,14 +50,7 @@ func (ctx *Context) RedirectToFirst(location ...string) {
continue
}

// Unfortunately browsers consider a redirect Location with preceding "//", "\\" and "/\" as meaning redirect to "http(s)://REST_OF_PATH"
// Therefore we should ignore these redirect locations to prevent open redirects
if len(loc) > 1 && (loc[0] == '/' || loc[0] == '\\') && (loc[1] == '/' || loc[1] == '\\') {
continue
}

u, err := url.Parse(loc)
if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(loc), strings.ToLower(setting.AppURL))) {
if httplib.IsRiskyRedirectURL(loc) {
continue
}

Expand Down
27 changes: 27 additions & 0 deletions modules/httplib/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"net/url"
"strings"

"code.gitea.io/gitea/modules/setting"
)

// IsRiskyRedirectURL returns true if the URL is considered risky for redirects
func IsRiskyRedirectURL(s string) bool {
// Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
// Therefore we should ignore these redirect locations to prevent open redirects
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
return true
}

u, err := url.Parse(s)
if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(s), strings.ToLower(setting.AppURL))) {
return true
}

return false
}
38 changes: 38 additions & 0 deletions modules/httplib/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"testing"

"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
)

func TestIsRiskyRedirectURL(t *testing.T) {
setting.AppURL = "http://localhost:3000/"
tests := []struct {
input string
want bool
}{
{"", false},
{"foo", false},
{"/", false},
{"/foo?k=%20#abc", false},

{"//", true},
{"\\\\", true},
{"/\\", true},
{"\\/", true},
{"mail:[email protected]", true},
{"https://test.com", true},
{setting.AppURL + "/foo", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input))
})
}
}
19 changes: 18 additions & 1 deletion modules/test/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@ package test

import (
"net/http"
"net/http/httptest"
"strings"

"code.gitea.io/gitea/modules/json"
)

// RedirectURL returns the redirect URL of a http response.
// It also works for JSONRedirect: `{"redirect": "..."}`
func RedirectURL(resp http.ResponseWriter) string {
return resp.Header().Get("Location")
loc := resp.Header().Get("Location")
if loc != "" {
return loc
}
if r, ok := resp.(*httptest.ResponseRecorder); ok {
m := map[string]any{}
err := json.Unmarshal(r.Body.Bytes(), &m)
if err == nil {
if loc, ok := m["redirect"].(string); ok {
return loc
}
}
}
return ""
}

func IsNormalPageCompleted(s string) bool {
Expand Down
26 changes: 26 additions & 0 deletions routers/common/redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package common

import (
"net/http"

"code.gitea.io/gitea/modules/httplib"
)

// FetchRedirectDelegate helps the "fetch" requests to redirect to the correct location
func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
// When use "fetch" to post requests and the response is a redirect, browser's "location.href = uri" has limitations.
// 1. change "location" from old "/foo" to new "/foo#hash", the browser will not reload the page.
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
// then frontend needs this delegate to redirect to the new location with hash correctly.
redirect := req.PostFormValue("redirect")
if httplib.IsRiskyRedirectURL(redirect) {
resp.WriteHeader(http.StatusBadRequest)
return
}
resp.Header().Add("Location", redirect)
resp.WriteHeader(http.StatusSeeOther)
}
2 changes: 2 additions & 0 deletions routers/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ func NormalRoutes(ctx context.Context) *web.Route {
r.Mount("/api/v1", apiv1.Routes(ctx))
r.Mount("/api/internal", private.Routes())

r.Post("/-/fetch-redirect", common.FetchRedirectDelegate)

if setting.Packages.Enabled {
// This implements package support for most package managers
r.Mount("/api/packages", packages_router.CommonRoutes(ctx))
Expand Down
31 changes: 12 additions & 19 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,12 +1134,12 @@ func NewIssuePost(ctx *context.Context) {
}

if ctx.HasError() {
ctx.HTML(http.StatusOK, tplIssueNew)
ctx.JSONError(ctx.GetErrMsg())
return
}

if util.IsEmptyString(form.Title) {
ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplIssueNew, form)
ctx.JSONError(ctx.Tr("repo.issues.new.title_empty"))
return
}

Expand Down Expand Up @@ -1184,9 +1184,9 @@ func NewIssuePost(ctx *context.Context) {

log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
ctx.Redirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10))
ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10))
} else {
ctx.Redirect(issue.Link())
ctx.JSONRedirect(issue.Link())
}
}

Expand Down Expand Up @@ -2777,8 +2777,7 @@ func NewComment(ctx *context.Context) {
}

if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked"))
ctx.Redirect(issue.Link())
ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked"))
return
}

Expand All @@ -2788,8 +2787,7 @@ func NewComment(ctx *context.Context) {
}

if ctx.HasError() {
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
ctx.Redirect(issue.Link())
ctx.JSONError(ctx.GetErrMsg())
return
}

Expand All @@ -2809,8 +2807,7 @@ func NewComment(ctx *context.Context) {
pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow)
if err != nil {
if !issues_model.IsErrPullRequestNotExist(err) {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
return
}
}
Expand Down Expand Up @@ -2841,8 +2838,7 @@ func NewComment(ctx *context.Context) {
}
if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok {
// todo localize
ctx.Flash.Error("The origin branch is delete, cannot reopen.")
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index))
ctx.JSONError("The origin branch is delete, cannot reopen.")
return
}
headBranchRef := pull.GetGitHeadBranchRefName()
Expand Down Expand Up @@ -2882,11 +2878,9 @@ func NewComment(ctx *context.Context) {

if issues_model.IsErrDependenciesLeft(err) {
if issue.IsPull {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
} else {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
}
return
}
Expand All @@ -2899,7 +2893,6 @@ func NewComment(ctx *context.Context) {
log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
}
}

}

// Redirect to comment hashtag if there is any actual content.
Expand All @@ -2908,9 +2901,9 @@ func NewComment(ctx *context.Context) {
typeName = "pulls"
}
if comment != nil {
ctx.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
} else {
ctx.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
}
}()

Expand Down
4 changes: 2 additions & 2 deletions templates/repo/issue/new_form.tmpl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<form class="issue-content ui comment form" id="new-issue" action="{{.Link}}" method="post">
<form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
{{if .Flash}}
<div class="sixteen wide column">
Expand Down Expand Up @@ -35,7 +35,7 @@
{{template "repo/issue/comment_tab" .}}
{{end}}
<div class="text right">
<button class="ui green button loading-button" tabindex="6">
<button class="ui green button" tabindex="6">
{{if .PageIsComparePull}}
{{.locale.Tr "repo.pulls.create"}}
{{else}}
Expand Down
9 changes: 4 additions & 5 deletions templates/repo/issue/view_content.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,27 @@
{{avatar $.Context .SignedUser 40}}
</a>
<div class="content">
<form class="ui segment form" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post">
<form class="ui segment form form-fetch-action" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post">
{{template "repo/issue/comment_tab" .}}
{{.CsrfTokenHtml}}
<input id="status" name="status" type="hidden">
<div class="field footer">
<div class="text right">
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}}
{{if .Issue.IsClosed}}
<button id="status-button" class="ui green basic button" tabindex="6" data-status="{{.locale.Tr "repo.issues.reopen_issue"}}" data-status-and-comment="{{.locale.Tr "repo.issues.reopen_comment_issue"}}" data-status-val="reopen">
<button id="status-button" class="ui green basic button" tabindex="6" data-status="{{.locale.Tr "repo.issues.reopen_issue"}}" data-status-and-comment="{{.locale.Tr "repo.issues.reopen_comment_issue"}}" name="status" value="reopen">
{{.locale.Tr "repo.issues.reopen_issue"}}
</button>
{{else}}
{{$closeTranslationKey := "repo.issues.close"}}
{{if .Issue.IsPull}}
{{$closeTranslationKey = "repo.pulls.close"}}
{{end}}
<button id="status-button" class="ui red basic button" tabindex="6" data-status="{{.locale.Tr $closeTranslationKey}}" data-status-and-comment="{{.locale.Tr "repo.issues.close_comment_issue"}}" data-status-val="close">
<button id="status-button" class="ui red basic button" tabindex="6" data-status="{{.locale.Tr $closeTranslationKey}}" data-status-and-comment="{{.locale.Tr "repo.issues.close_comment_issue"}}" name="status" value="close">
{{.locale.Tr $closeTranslationKey}}
</button>
{{end}}
{{end}}
<button class="ui green button loading-button" tabindex="5">
<button class="ui green button" tabindex="5">
{{.locale.Tr "repo.issues.create_comment"}}
</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestCreateIssueAttachment(t *testing.T) {
}

req = NewRequestWithValues(t, "POST", link, postData)
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)
test.RedirectURL(resp) // check that redirect URL exists

// Validate that attachment is available
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
"title": title,
"content": content,
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)

issueURL := test.RedirectURL(resp)
req = NewRequest(t, "GET", issueURL)
Expand Down Expand Up @@ -165,7 +165,7 @@ func testIssueAddComment(t *testing.T, session *TestSession, issueURL, content,
"content": content,
"status": status,
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)

req = NewRequest(t, "GET", test.RedirectURL(resp))
resp = session.MakeRequest(t, req, http.StatusOK)
Expand Down
Loading