1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-08-04 17:35:21 +02:00

feat(ui): add links to assigners in issue comments (#8264)

When a user is assigning an issue or PR to another user, an entry is posted in the issue comment list. This PR adds a link to the one assigning the issue. Additionally, tests for preventing XSS vulnerabilities in label rendering were added.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8264
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Gusted 2025-07-09 23:23:33 +02:00
commit 8ac572682b
7 changed files with 110 additions and 33 deletions

View file

@ -3,6 +3,7 @@
repo_id: 1 repo_id: 1
org_id: 0 org_id: 0
name: label1 name: label1
description: 'First label'
color: '#abcdef' color: '#abcdef'
exclusive: false exclusive: false
num_issues: 2 num_issues: 2
@ -107,3 +108,26 @@
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0 archived_unix: 0
-
id: 11
repo_id: 3
org_id: 0
name: " <script>malicious</script> /'?&"
description: "Malicious label ' <script>malicious</script>"
color: '#000000'
exclusive: true
num_issues: 0
num_closed_issues: 0
archived_unix: 0
-
id: 12
repo_id: 3
org_id: 0
name: 'archived label<>'
color: '#000000'
exclusive: false
num_issues: 0
num_closed_issues: 0
archived_unix: 2991092130

View file

@ -188,6 +188,7 @@ func NewFuncMap() template.FuncMap {
"RenderMarkdownToHtml": RenderMarkdownToHtml, "RenderMarkdownToHtml": RenderMarkdownToHtml,
"RenderLabel": RenderLabel, "RenderLabel": RenderLabel,
"RenderLabels": RenderLabels, "RenderLabels": RenderLabels,
"RenderUser": RenderUser,
"RenderReviewRequest": RenderReviewRequest, "RenderReviewRequest": RenderReviewRequest,
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View file

@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html"
"html/template" "html/template"
"math" "math"
"net/url" "net/url"
@ -15,6 +16,7 @@ import (
"unicode" "unicode"
issues_model "forgejo.org/models/issues" issues_model "forgejo.org/models/issues"
user_model "forgejo.org/models/user"
"forgejo.org/modules/emoji" "forgejo.org/modules/emoji"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/markup" "forgejo.org/modules/markup"
@ -26,7 +28,7 @@ import (
// RenderCommitMessage renders commit message with XSS-safe and special links. // RenderCommitMessage renders commit message with XSS-safe and special links.
func RenderCommitMessage(ctx context.Context, msg string, metas map[string]string) template.HTML { func RenderCommitMessage(ctx context.Context, msg string, metas map[string]string) template.HTML {
cleanMsg := template.HTMLEscapeString(msg) cleanMsg := html.EscapeString(msg)
// we can safely assume that it will not return any error, since there // we can safely assume that it will not return any error, since there
// shouldn't be any special HTML. // shouldn't be any special HTML.
fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
@ -63,7 +65,7 @@ func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlDefault string,
Ctx: ctx, Ctx: ctx,
DefaultLink: urlDefault, DefaultLink: urlDefault,
Metas: metas, Metas: metas,
}, template.HTMLEscapeString(msgLine)) }, html.EscapeString(msgLine))
if err != nil { if err != nil {
log.Error("RenderCommitMessageSubject: %v", err) log.Error("RenderCommitMessageSubject: %v", err)
return template.HTML("") return template.HTML("")
@ -88,7 +90,7 @@ func RenderCommitBody(ctx context.Context, msg string, metas map[string]string)
renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
Ctx: ctx, Ctx: ctx,
Metas: metas, Metas: metas,
}, template.HTMLEscapeString(msgLine)) }, html.EscapeString(msgLine))
if err != nil { if err != nil {
log.Error("RenderCommitMessage: %v", err) log.Error("RenderCommitMessage: %v", err)
return "" return ""
@ -122,7 +124,7 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
Ctx: ctx, Ctx: ctx,
Metas: metas, Metas: metas,
}, template.HTMLEscapeString(text)) }, html.EscapeString(text))
if err != nil { if err != nil {
log.Error("RenderIssueTitle: %v", err) log.Error("RenderIssueTitle: %v", err)
return template.HTML("") return template.HTML("")
@ -132,7 +134,7 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
// RenderRefIssueTitle renders referenced issue/pull title with defined post processors // RenderRefIssueTitle renders referenced issue/pull title with defined post processors
func RenderRefIssueTitle(ctx context.Context, text string) template.HTML { func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text)) renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, html.EscapeString(text))
if err != nil { if err != nil {
log.Error("RenderRefIssueTitle: %v", err) log.Error("RenderRefIssueTitle: %v", err)
return "" return ""
@ -150,7 +152,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
labelScope = label.ExclusiveScope() labelScope = label.ExclusiveScope()
) )
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) description := emoji.ReplaceAliases(html.EscapeString(label.Description))
if label.IsArchived() { if label.IsArchived() {
archivedCSSClass = "archived-label" archivedCSSClass = "archived-label"
@ -212,7 +214,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
// RenderEmoji renders html text with emoji post processors // RenderEmoji renders html text with emoji post processors
func RenderEmoji(ctx context.Context, text string) template.HTML { func RenderEmoji(ctx context.Context, text string) template.HTML {
renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx}, renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx},
template.HTMLEscapeString(text)) html.EscapeString(text))
if err != nil { if err != nil {
log.Error("RenderEmoji: %v", err) log.Error("RenderEmoji: %v", err)
return template.HTML("") return template.HTML("")
@ -263,10 +265,20 @@ func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issu
return template.HTML(htmlCode) return template.HTML(htmlCode)
} }
func RenderUser(ctx context.Context, user user_model.User) template.HTML {
if user.ID > 0 {
return template.HTML(fmt.Sprintf(
"<a href='%s' rel='nofollow'><strong>%s</strong></a>",
user.HomeLink(), html.EscapeString(user.GetDisplayName())))
}
return template.HTML(fmt.Sprintf("<strong>%s</strong>",
html.EscapeString(user.GetDisplayName())))
}
func RenderReviewRequest(users []issues_model.RequestReviewTarget) template.HTML { func RenderReviewRequest(users []issues_model.RequestReviewTarget) template.HTML {
usernames := make([]string, 0, len(users)) usernames := make([]string, 0, len(users))
for _, user := range users { for _, user := range users {
usernames = append(usernames, template.HTMLEscapeString(user.Name())) usernames = append(usernames, html.EscapeString(user.Name()))
} }
htmlCode := `<span class="review-request-list">` htmlCode := `<span class="review-request-list">`

View file

@ -11,6 +11,9 @@ import (
"forgejo.org/models/db" "forgejo.org/models/db"
issues_model "forgejo.org/models/issues" issues_model "forgejo.org/models/issues"
"forgejo.org/models/unittest" "forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/modules/translation" "forgejo.org/modules/translation"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -215,9 +218,51 @@ func TestRenderLabels(t *testing.T) {
tr := &translation.MockLocale{} tr := &translation.MockLocale{}
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
labelScoped := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
labelMalicious := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 11})
labelArchived := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 12})
assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false), rendered := RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false)
"user2/repo1/issues?labels=1") assert.Contains(t, rendered, "user2/repo1/issues?labels=1")
assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true), assert.Contains(t, rendered, ">label1<")
"user2/repo1/pulls?labels=1") assert.Contains(t, rendered, "title='First label'")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true)
assert.Contains(t, rendered, "user2/repo1/pulls?labels=1")
assert.Contains(t, rendered, ">label1<")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelScoped}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=7")
assert.Contains(t, rendered, ">scope<")
assert.Contains(t, rendered, ">label1<")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelMalicious}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=11")
assert.Contains(t, rendered, "> &lt;script&gt;malicious&lt;/script&gt; <")
assert.Contains(t, rendered, ">&#39;?&amp;<")
assert.Contains(t, rendered, "title='Malicious label &#39; &lt;script&gt;malicious&lt;/script&gt;'")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelArchived}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=12")
assert.Contains(t, rendered, ">archived label&lt;&gt;<")
assert.Contains(t, rendered, "title='repo.issues.archived_label_description'")
}
func TestRenderUser(t *testing.T) {
unittest.PrepareTestEnv(t)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
ghost := user_model.NewGhostUser()
assert.Contains(t, RenderUser(db.DefaultContext, *user),
"<a href='/user2' rel='nofollow'><strong>user2</strong></a>")
assert.Contains(t, RenderUser(db.DefaultContext, *org),
"<a href='/org3' rel='nofollow'><strong>org3</strong></a>")
assert.Contains(t, RenderUser(db.DefaultContext, *ghost),
"<strong>Ghost</strong>")
defer test.MockVariableValue(&setting.UI.DefaultShowFullName, true)()
assert.Contains(t, RenderUser(db.DefaultContext, *user),
"<a href='/user2' rel='nofollow'><strong>&lt; U&lt;se&gt;r Tw&lt;o &gt; &gt;&lt;</strong></a>")
assert.Contains(t, RenderUser(db.DefaultContext, *org),
"<a href='/org3' rel='nofollow'><strong>&lt;&lt;&lt;&lt; &gt;&gt; &gt;&gt; &gt; &gt;&gt; &gt; &gt;&gt;&gt; &gt;&gt;</strong></a>")
assert.Contains(t, RenderUser(db.DefaultContext, *ghost),
"<strong>Ghost</strong>")
} }

View file

@ -24,10 +24,11 @@ func TestLabel_ToLabel(t *testing.T) {
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: label.RepoID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: label.RepoID})
assert.Equal(t, &api.Label{ assert.Equal(t, &api.Label{
ID: label.ID, ID: label.ID,
Name: label.Name, Name: label.Name,
Color: "abcdef", Color: "abcdef",
URL: fmt.Sprintf("%sapi/v1/repos/user2/repo1/labels/%d", setting.AppURL, label.ID), Description: label.Description,
URL: fmt.Sprintf("%sapi/v1/repos/user2/repo1/labels/%d", setting.AppURL, label.ID),
}, ToLabel(label, repo, nil)) }, ToLabel(label, repo, nil))
} }

View file

@ -221,27 +221,23 @@
{{else if and (eq .Type 9) (gt .AssigneeID 0)}} {{else if and (eq .Type 9) (gt .AssigneeID 0)}}
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-person"}}</span> <span class="badge">{{svg "octicon-person"}}</span>
{{if .RemovedAssignee}} {{template "shared/user/avatarlink" dict "user" .Assignee}}
{{template "shared/user/avatarlink" dict "user" .Assignee}} <span class="text grey muted-links">
<span class="text grey muted-links"> {{template "shared/user/authorlink" .Assignee}}
{{template "shared/user/authorlink" .Assignee}} {{if .RemovedAssignee}}
{{if eq .Poster.ID .Assignee.ID}} {{if eq .Poster.ID .Assignee.ID}}
{{ctx.Locale.Tr "repo.issues.remove_self_assignment" $createdStr}} {{ctx.Locale.Tr "repo.issues.remove_self_assignment" $createdStr}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.issues.remove_assignee_at" .Poster.GetDisplayName $createdStr}} {{ctx.Locale.Tr "repo.issues.remove_assignee_at" (RenderUser $.Context .Poster) $createdStr}}
{{end}} {{end}}
</span> {{else}}
{{else}}
{{template "shared/user/avatarlink" dict "user" .Assignee}}
<span class="text grey muted-links">
{{template "shared/user/authorlink" .Assignee}}
{{if eq .Poster.ID .AssigneeID}} {{if eq .Poster.ID .AssigneeID}}
{{ctx.Locale.Tr "repo.issues.self_assign_at" $createdStr}} {{ctx.Locale.Tr "repo.issues.self_assign_at" $createdStr}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.issues.add_assignee_at" .Poster.GetDisplayName $createdStr}} {{ctx.Locale.Tr "repo.issues.add_assignee_at" (RenderUser $.Context .Poster) $createdStr}}
{{end}} {{end}}
</span> {{end}}
{{end}} </span>
</div> </div>
{{else if eq .Type 10}} {{else if eq .Type 10}}
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">

View file

@ -223,15 +223,13 @@ func TestIssueCommentChangeAssignee(t *testing.T) {
testIssueCommentChangeEvent(t, htmlDoc, "2041", testIssueCommentChangeEvent(t, htmlDoc, "2041",
"octicon-person", "User One", "/user1", "octicon-person", "User One", "/user1",
[]string{"user1 was unassigned by user2"}, []string{"user1 was unassigned by user2"},
[]string{"/user1"}) []string{"/user1", "/user2"})
// []string{"/user1", "/user2"})
// Add other // Add other
testIssueCommentChangeEvent(t, htmlDoc, "2042", testIssueCommentChangeEvent(t, htmlDoc, "2042",
"octicon-person", "< U<se>r Tw<o > ><", "/user2", "octicon-person", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 was assigned by user1"}, []string{"user2 was assigned by user1"},
[]string{"/user2"}) []string{"/user2", "/user1"})
// []string{"/user2", "/user1"})
// Self-remove // Self-remove
testIssueCommentChangeEvent(t, htmlDoc, "2043", testIssueCommentChangeEvent(t, htmlDoc, "2043",