Skip to content

Commit cfca129

Browse files
pieerclaude
andauthored
Fix #205: restrict "Submit Review" options to what the user may actually do (#342)
* Fix #205: restrict "Submit Review" options to what the user may actually do Users who are neither the change request author nor the article owner (nor anyone with write access) were offered all four review options. "Approve" silently failed and "Close" returned a 500 because there is no "close" review type server-side. Random users now only get the "Comment" option: "Approve", "Request changes" and "Close" are no longer rendered for them, in the same spirit as #192, and the server rejects such submissions with a 403 carrying an error message instead of failing silently or with a 500. Behaviour for the article owner / users with write access and for the change request author (greyed-out approve/reject, per #192) is unchanged. Co-authored-by: Pierre Schweiger <schweiger.pierre@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix #205: address review feedback on the Submit Review permission check - derive the permission decision from `form.ReviewType()` instead of the raw form strings, so renaming a form value can no longer silently fail open; only "close", which has no `ReviewType` of its own, still matches the raw value - replace the package-local `jsonForbidden` helper with a `(*context.Context).JSONForbidden` sibling of `JSONError`/`JSONErrorNotFound` - answer an unknown review type with a 400 `JSONError` instead of a 500 - template: rename `$showSelfTooltip` to `$isChangeRequestAuthor` now that it is also a permission input, and merge the duplicated approve/reject guard - tests: use the exported `HTMLDoc.Find` helper and assert the `disabled` attribute the author's approve/reject options are supposed to carry Co-authored-by: Pierre Schweiger <schweiger.pierre@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix #205: satisfy perfsprint on the unknown-review-type error The JSONError added while addressing review feedback used fmt.Sprintf with a single trailing %s, which perfsprint rejects in favour of concatenation. This was failing lint-backend, lint-go-gogit and lint-go-windows on PR #342; the tests themselves were green. Co-authored-by: Pierre Schweiger <schweiger.pierre@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6637afb commit cfca129

5 files changed

Lines changed: 149 additions & 16 deletions

File tree

custom/options/locale/locale_en-US.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,6 +1935,9 @@ issues.dependency.add_error_cannot_create_circular = You cannot create a depende
19351935
issues.dependency.add_error_dep_not_same_repo = Both issues must be in the same repository.
19361936
issues.review.self.approval = You cannot approve your own change request.
19371937
issues.review.self.rejection = You cannot request changes on your own change request.
1938+
issues.review.no_permission.approval = You do not have permission to approve this change request.
1939+
issues.review.no_permission.rejection = You do not have permission to request changes on this change request.
1940+
issues.review.no_permission.close = You do not have permission to close this change request.
19381941
issues.review.approve = "approved these changes %s"
19391942
issues.review.comment = "reviewed %s"
19401943
issues.review.dismissed = "dismissed %s's review %s"

routers/web/repo/pull_review.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,37 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
220220
}
221221
}
222222

223+
// checkReviewDecisionPermission verifies that the doer is allowed to submit the
224+
// requested review type. Approving and requesting changes require write access
225+
// to the repository (the article), closing additionally allows the change
226+
// request author. Everybody else is limited to plain comments.
227+
// It returns false (and writes the response) when the submission is rejected.
228+
func checkReviewDecisionPermission(ctx *context.Context, issue *issues_model.Issue, form *forms.SubmitReviewForm) bool {
229+
if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
230+
return true
231+
}
232+
233+
switch form.ReviewType() {
234+
case issues_model.ReviewTypeApprove:
235+
ctx.JSONForbidden(ctx.Locale.TrString("repo.issues.review.no_permission.approval"))
236+
return false
237+
case issues_model.ReviewTypeReject:
238+
ctx.JSONForbidden(ctx.Locale.TrString("repo.issues.review.no_permission.rejection"))
239+
return false
240+
}
241+
242+
// "close" has no ReviewType of its own, so it can only be matched on the raw
243+
// form value; the change request author may close their own change request.
244+
if form.Type == "close" {
245+
isPoster := ctx.IsSigned && ctx.Doer != nil && issue.IsPoster(ctx.Doer.ID)
246+
if !isPoster {
247+
ctx.JSONForbidden(ctx.Locale.TrString("repo.issues.review.no_permission.close"))
248+
return false
249+
}
250+
}
251+
return true
252+
}
253+
223254
// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
224255
func SubmitReview(ctx *context.Context) {
225256
form := web.GetForm(ctx).(*forms.SubmitReviewForm)
@@ -236,10 +267,15 @@ func SubmitReview(ctx *context.Context) {
236267
return
237268
}
238269

270+
if !checkReviewDecisionPermission(ctx, issue, form) {
271+
return
272+
}
273+
239274
reviewType := form.ReviewType()
240275
switch reviewType {
241276
case issues_model.ReviewTypeUnknown:
242-
ctx.ServerError("ReviewType", fmt.Errorf("unknown ReviewType: %s", form.Type))
277+
// an unknown type is a client error, not a server one
278+
ctx.JSONError("unknown review type: " + form.Type)
243279
return
244280

245281
// can not approve/reject your own PR

services/context/context.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ func (ctx *Context) JSONError(msg any) {
263263
}
264264
}
265265

266+
// JSONForbidden is the 403 sibling of JSONError: the frontend's fetch-action
267+
// handler renders the "errorMessage" payload as an error toast.
268+
func (ctx *Context) JSONForbidden(msg string) {
269+
ctx.JSON(http.StatusForbidden, map[string]any{"errorMessage": msg, "renderFormat": "text"})
270+
}
271+
266272
func (ctx *Context) JSONErrorNotFound(optMsg ...string) {
267273
msg := util.OptionalArg(optMsg)
268274
if msg == "" {

templates/repo/diff/review_submit.tmpl

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,39 +36,43 @@
3636
</div>
3737
{{end}}
3838
<div class="review-radio-options">
39-
{{$showSelfTooltip := (and $.IsSigned ($.Issue.IsPoster $.SignedUser.ID))}}
39+
{{/* Only the article owner (or anyone with write access) may approve or request
40+
changes; the change request author may additionally close their own change
41+
request. Everybody else only gets the "Comment" option. */}}
42+
{{$isChangeRequestAuthor := (and $.IsSigned ($.Issue.IsPoster $.SignedUser.ID))}}
43+
{{$canReviewDecision := (or $.HasIssuesOrPullsWritePermission $isChangeRequestAuthor)}}
4044
<label class="review-radio-option">
4145
<input type="radio" name="type" value="comment" checked>
4246
<div class="review-radio-content">
4347
<span class="review-radio-label">{{ctx.Locale.Tr "repo.diff.review.comment"}}</span>
4448
<span class="review-radio-desc">{{ctx.Locale.Tr "repo.diff.review.comment_desc"}}</span>
4549
</div>
4650
</label>
47-
{{if not $.Issue.IsClosed}}
48-
<label class="review-radio-option{{if $showSelfTooltip}} disabled{{end}}" {{if $showSelfTooltip}}data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_approve"}}"{{end}}>
49-
<input type="radio" name="type" value="approve" {{if $showSelfTooltip}}disabled{{end}}>
51+
{{if and $canReviewDecision (not $.Issue.IsClosed)}}
52+
<label class="review-radio-option{{if $isChangeRequestAuthor}} disabled{{end}}" {{if $isChangeRequestAuthor}}data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_approve"}}"{{end}}>
53+
<input type="radio" name="type" value="approve" {{if $isChangeRequestAuthor}}disabled{{end}}>
5054
<div class="review-radio-content">
5155
<span class="review-radio-label">{{ctx.Locale.Tr "repo.diff.review.approve"}}</span>
5256
<span class="review-radio-desc">{{ctx.Locale.Tr "repo.diff.review.approve_desc"}}</span>
5357
</div>
5458
</label>
55-
{{end}}
56-
{{if not $.Issue.IsClosed}}
57-
<label class="review-radio-option{{if $showSelfTooltip}} disabled{{end}}" {{if $showSelfTooltip}}data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_reject"}}"{{end}}>
58-
<input type="radio" name="type" value="reject" {{if $showSelfTooltip}}disabled{{end}}>
59+
<label class="review-radio-option{{if $isChangeRequestAuthor}} disabled{{end}}" {{if $isChangeRequestAuthor}}data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_reject"}}"{{end}}>
60+
<input type="radio" name="type" value="reject" {{if $isChangeRequestAuthor}}disabled{{end}}>
5961
<div class="review-radio-content">
6062
<span class="review-radio-label">{{ctx.Locale.Tr "repo.diff.review.reject"}}</span>
6163
<span class="review-radio-desc">{{ctx.Locale.Tr "repo.diff.review.reject_desc"}}</span>
6264
</div>
6365
</label>
6466
{{end}}
65-
<label class="review-radio-option">
66-
<input type="radio" name="type" value="close">
67-
<div class="review-radio-content">
68-
<span class="review-radio-label">{{ctx.Locale.Tr "repo.diff.review.close"}}</span>
69-
<span class="review-radio-desc">{{ctx.Locale.Tr "repo.diff.review.close_desc"}}</span>
70-
</div>
71-
</label>
67+
{{if $canReviewDecision}}
68+
<label class="review-radio-option">
69+
<input type="radio" name="type" value="close">
70+
<div class="review-radio-content">
71+
<span class="review-radio-label">{{ctx.Locale.Tr "repo.diff.review.close"}}</span>
72+
<span class="review-radio-desc">{{ctx.Locale.Tr "repo.diff.review.close_desc"}}</span>
73+
</div>
74+
</label>
75+
{{end}}
7276
</div>
7377
</div>
7478
<div class="review-modal-footer">

tests/integration/pull_review_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
files_service "code.gitea.io/gitea/services/repository/files"
2424
"code.gitea.io/gitea/tests"
2525

26+
"github.com/PuerkitoBio/goquery"
2627
"github.com/stretchr/testify/assert"
2728
)
2829

@@ -251,6 +252,89 @@ func TestPullView_GivenApproveOrRejectReviewOnClosedPR(t *testing.T) {
251252
})
252253
}
253254

255+
// TestPullView_SubmitReviewPermissions checks that a user without write access
256+
// to the change request, and who isn't its author, can only submit a plain
257+
// comment review: "approve", "reject" and "close" must be rejected server-side
258+
// (issue #205) instead of silently doing nothing or returning a 500.
259+
func TestPullView_SubmitReviewPermissions(t *testing.T) {
260+
defer tests.PrepareTestEnv(t)()
261+
262+
// user2 owns repo1, user1 is the author of pull request #3, user5 is a
263+
// random user with neither write access nor authorship.
264+
const owner, repo, pullNumber = "user2", "repo1", "3"
265+
266+
getCSRF := func(session *TestSession) string {
267+
req := NewRequest(t, "GET", path.Join("/", owner, repo, "pulls", pullNumber))
268+
resp := session.MakeRequest(t, req, http.StatusOK)
269+
return NewHTMLParser(t, resp.Body).GetCSRF()
270+
}
271+
272+
t.Run("RandomUser", func(t *testing.T) {
273+
session := loginUser(t, "user5")
274+
csrf := getCSRF(session)
275+
276+
testSubmitReview(t, session, csrf, owner, repo, pullNumber, "", "approve", http.StatusForbidden)
277+
testSubmitReview(t, session, csrf, owner, repo, pullNumber, "", "reject", http.StatusForbidden)
278+
testSubmitReview(t, session, csrf, owner, repo, pullNumber, "", "close", http.StatusForbidden)
279+
// commenting stays available
280+
testSubmitReview(t, session, csrf, owner, repo, pullNumber, "", "comment", http.StatusOK)
281+
})
282+
283+
t.Run("Author", func(t *testing.T) {
284+
// the change request author may not approve/reject (handled elsewhere),
285+
// but must not be blocked by the write-access check
286+
session := loginUser(t, "user1")
287+
csrf := getCSRF(session)
288+
289+
// "close" is not turned into a permission error for the author. It does
290+
// not succeed either — there is no "close" review type server-side, which
291+
// is tracked separately as issue #347 — so only the absence of the 403
292+
// this PR introduces can be asserted here.
293+
resp := testSubmitReview(t, session, csrf, owner, repo, pullNumber, "", "close", NoExpectedStatus)
294+
assert.NotEqual(t, http.StatusForbidden, resp.Code)
295+
})
296+
}
297+
298+
// TestPullView_SubmitReviewModalOptions checks that the disallowed options are
299+
// not even rendered in the "Submit Review" modal for a user without permission.
300+
func TestPullView_SubmitReviewModalOptions(t *testing.T) {
301+
defer tests.PrepareTestEnv(t)()
302+
303+
filesLink := "/user2/repo1/pulls/3/files"
304+
reviewModal := func(session *TestSession) *HTMLDoc {
305+
req := NewRequest(t, "GET", filesLink)
306+
resp := session.MakeRequest(t, req, http.StatusOK)
307+
return NewHTMLParser(t, resp.Body)
308+
}
309+
reviewOptions := func(htmlDoc *HTMLDoc) []string {
310+
var values []string
311+
htmlDoc.Find(`.review-radio-options input[name="type"]`).Each(func(_ int, s *goquery.Selection) {
312+
value, _ := s.Attr("value")
313+
values = append(values, value)
314+
})
315+
return values
316+
}
317+
318+
// a random user only gets the "comment" option
319+
assert.Equal(t, []string{"comment"}, reviewOptions(reviewModal(loginUser(t, "user5"))))
320+
321+
// the repository owner keeps every option, all of them selectable
322+
ownerDoc := reviewModal(loginUser(t, "user2"))
323+
assert.ElementsMatch(t, []string{"comment", "approve", "reject", "close"}, reviewOptions(ownerDoc))
324+
for _, value := range []string{"approve", "reject"} {
325+
_, disabled := ownerDoc.Find(`.review-radio-options input[name="type"][value="` + value + `"]`).Attr("disabled")
326+
assert.False(t, disabled, "the owner's %q option should be selectable", value)
327+
}
328+
329+
// the change request author keeps the approve/reject options, but disabled (#192)
330+
authorDoc := reviewModal(loginUser(t, "user1"))
331+
assert.ElementsMatch(t, []string{"comment", "approve", "reject", "close"}, reviewOptions(authorDoc))
332+
for _, value := range []string{"approve", "reject"} {
333+
_, disabled := authorDoc.Find(`.review-radio-options input[name="type"][value="` + value + `"]`).Attr("disabled")
334+
assert.True(t, disabled, "the author's %q option should be disabled", value)
335+
}
336+
}
337+
254338
func testSubmitReview(t *testing.T, session *TestSession, csrf, owner, repo, pullNumber, commitID, reviewType string, expectedSubmitStatus int) *httptest.ResponseRecorder {
255339
options := map[string]string{
256340
"_csrf": csrf,

0 commit comments

Comments
 (0)