fix(notifications): escape markdown in user-controlled strings in email lines
Task titles, project titles, team names, doer/assignee names, and API token titles were interpolated raw into Line(...) calls whose content is rendered to HTML by goldmark and then sanitized with bluemonday UGCPolicy. UGCPolicy intentionally allows safe <a href> and <img src> with http/https URLs, so a title containing Markdown link or image syntax would survive sanitization as a working phishing link or tracking pixel in a legitimate Vikunja email. Introduce notifications.EscapeMarkdown, which prefixes every CommonMark §2.4 backslash-escapable ASCII punctuation character — including '<' so autolinks like `<https://evil.com>` are neutralized before reaching goldmark — with a backslash. Apply it to every user-controlled argument of every Line(...) call in pkg/models that feeds into an i18n template, and to the hand-built "* [title](url) (project)" Markdown link in the overdue-tasks digest notification. Also escape the migration error string in MigrationFailedNotification, an additional sink not listed in the advisory (error messages can carry user-controlled content from the external migration source). Subject(...), Greeting(...), and CreateConversationalHeader(...) are left unchanged: Subject is passed directly to the mail library and is not markdown-rendered, Greeting is rendered via html/template's built-in HTML escaping without markdown, and the conversational header is sanitized as raw HTML by bluemonday in mail_render.go. Fixes GHSA-45q4-x4r9-8fqj.
This commit is contained in:
parent
aa2b8c43f1
commit
0f3730d045
|
|
@ -33,7 +33,7 @@ func (n *APITokenExpiringWeekNotification) ToMail(lang string) *notifications.Ma
|
||||||
return notifications.NewMail().
|
return notifications.NewMail().
|
||||||
Subject(i18n.T(lang, "notifications.api_token.expiring.week.subject", n.Token.Title)).
|
Subject(i18n.T(lang, "notifications.api_token.expiring.week.subject", n.Token.Title)).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.api_token.expiring.week.message", n.Token.Title, n.Token.ExpiresAt.Format("2006-01-02"))).
|
Line(i18n.T(lang, "notifications.api_token.expiring.week.message", notifications.EscapeMarkdown(n.Token.Title), n.Token.ExpiresAt.Format("2006-01-02"))).
|
||||||
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
||||||
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +60,7 @@ func (n *APITokenExpiringDayNotification) ToMail(lang string) *notifications.Mai
|
||||||
return notifications.NewMail().
|
return notifications.NewMail().
|
||||||
Subject(i18n.T(lang, "notifications.api_token.expiring.day.subject", n.Token.Title)).
|
Subject(i18n.T(lang, "notifications.api_token.expiring.day.subject", n.Token.Title)).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.api_token.expiring.day.message", n.Token.Title, n.Token.ExpiresAt.Format("2006-01-02"))).
|
Line(i18n.T(lang, "notifications.api_token.expiring.day.message", notifications.EscapeMarkdown(n.Token.Title), n.Token.ExpiresAt.Format("2006-01-02"))).
|
||||||
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
||||||
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func (n *ReminderDueNotification) ToMail(lang string) *notifications.Mail {
|
||||||
To(n.User.Email).
|
To(n.User.Email).
|
||||||
Subject(i18n.T(lang, "notifications.task.reminder.subject", n.Task.Title, n.Project.Title)).
|
Subject(i18n.T(lang, "notifications.task.reminder.subject", n.Task.Title, n.Project.Title)).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.task.reminder.message", n.Task.Title, n.Project.Title)).
|
Line(i18n.T(lang, "notifications.task.reminder.message", notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Project.Title))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
|
Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
|
||||||
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +166,7 @@ func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
From(n.Doer.GetNameAndFromEmail()).
|
From(n.Doer.GetNameAndFromEmail()).
|
||||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())).
|
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_assignee", n.Doer.GetName(), n.Task.Title)).
|
Line(i18n.T(lang, "notifications.task.assigned.message_to_assignee", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Task.Title))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||||
IncludeLinkToSettings(lang)
|
IncludeLinkToSettings(lang)
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +177,7 @@ func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
From(n.Doer.GetNameAndFromEmail()).
|
From(n.Doer.GetNameAndFromEmail()).
|
||||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())).
|
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_others_self", n.Doer.GetName())).
|
Line(i18n.T(lang, "notifications.task.assigned.message_to_others_self", notifications.EscapeMarkdown(n.Doer.GetName()))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||||
IncludeLinkToSettings(lang)
|
IncludeLinkToSettings(lang)
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +187,7 @@ func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
From(n.Doer.GetNameAndFromEmail()).
|
From(n.Doer.GetNameAndFromEmail()).
|
||||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())).
|
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_others", n.Doer.GetName(), n.Assignee.GetName())).
|
Line(i18n.T(lang, "notifications.task.assigned.message_to_others", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Assignee.GetName()))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||||
IncludeLinkToSettings(lang)
|
IncludeLinkToSettings(lang)
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +217,7 @@ type TaskDeletedNotification struct {
|
||||||
func (n *TaskDeletedNotification) ToMail(lang string) *notifications.Mail {
|
func (n *TaskDeletedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
return notifications.NewMail().
|
return notifications.NewMail().
|
||||||
Subject(i18n.T(lang, "notifications.task.deleted.subject", n.Task.Title, n.Task.GetFullIdentifier())).
|
Subject(i18n.T(lang, "notifications.task.deleted.subject", n.Task.Title, n.Task.GetFullIdentifier())).
|
||||||
Line(i18n.T(lang, "notifications.task.deleted.message", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()))
|
Line(i18n.T(lang, "notifications.task.deleted.message", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Task.GetFullIdentifier())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToDB returns the TaskDeletedNotification notification in a format which can be saved in the db
|
// ToDB returns the TaskDeletedNotification notification in a format which can be saved in the db
|
||||||
|
|
@ -245,7 +245,7 @@ type ProjectCreatedNotification struct {
|
||||||
func (n *ProjectCreatedNotification) ToMail(lang string) *notifications.Mail {
|
func (n *ProjectCreatedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
return notifications.NewMail().
|
return notifications.NewMail().
|
||||||
Subject(i18n.T(lang, "notifications.project.created", n.Doer.GetName(), n.Project.Title)).
|
Subject(i18n.T(lang, "notifications.project.created", n.Doer.GetName(), n.Project.Title)).
|
||||||
Line(i18n.T(lang, "notifications.project.created", n.Doer.GetName(), n.Project.Title)).
|
Line(i18n.T(lang, "notifications.project.created", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Project.Title))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_project"), config.ServicePublicURL.GetString()+"projects/")
|
Action(i18n.T(lang, "notifications.common.actions.open_project"), config.ServicePublicURL.GetString()+"projects/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,7 +272,7 @@ func (n *TeamMemberAddedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
Subject(i18n.T(lang, "notifications.team.member_added.subject", n.Doer.GetName(), n.Team.Name)).
|
Subject(i18n.T(lang, "notifications.team.member_added.subject", n.Doer.GetName(), n.Team.Name)).
|
||||||
From(n.Doer.GetNameAndFromEmail()).
|
From(n.Doer.GetNameAndFromEmail()).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.Member.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.Member.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.team.member_added.message", n.Doer.GetName(), n.Team.Name)).
|
Line(i18n.T(lang, "notifications.team.member_added.message", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Team.Name))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_team"), config.ServicePublicURL.GetString()+"teams/"+strconv.FormatInt(n.Team.ID, 10)+"/edit")
|
Action(i18n.T(lang, "notifications.common.actions.open_team"), config.ServicePublicURL.GetString()+"teams/"+strconv.FormatInt(n.Team.ID, 10)+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ func (n *UndoneTaskOverdueNotification) ToMail(lang string) *notifications.Mail
|
||||||
IncludeLinkToSettings(lang).
|
IncludeLinkToSettings(lang).
|
||||||
Subject(i18n.T(lang, "notifications.task.overdue.subject", n.Task.Title, n.Project.Title)).
|
Subject(i18n.T(lang, "notifications.task.overdue.subject", n.Task.Title, n.Project.Title)).
|
||||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||||
Line(i18n.T(lang, "notifications.task.overdue.message", n.Task.Title, n.Project.Title, getOverdueSinceString(until, n.User.Language))).
|
Line(i18n.T(lang, "notifications.task.overdue.message", notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Project.Title), getOverdueSinceString(until, n.User.Language))).
|
||||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
|
Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
|
||||||
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
Line(i18n.T(lang, "notifications.common.have_nice_day"))
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +350,7 @@ func (n *UndoneTasksOverdueNotification) ToMail(lang string) *notifications.Mail
|
||||||
overdueLine := ""
|
overdueLine := ""
|
||||||
for _, task := range sortedTasks {
|
for _, task := range sortedTasks {
|
||||||
until := time.Until(task.DueDate).Round(1*time.Hour) * -1
|
until := time.Until(task.DueDate).Round(1*time.Hour) * -1
|
||||||
overdueLine += `* [` + task.Title + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `), ` + i18n.T("notifications.task.overdue.overdue", getOverdueSinceString(until, n.User.Language)) + "\n"
|
overdueLine += `* [` + notifications.EscapeMarkdown(task.Title) + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + notifications.EscapeMarkdown(n.Projects[task.ProjectID].Title) + `), ` + i18n.T("notifications.task.overdue.overdue", getOverdueSinceString(until, n.User.Language)) + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
return notifications.NewMail().
|
return notifications.NewMail().
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,15 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/config"
|
"code.vikunja.io/api/pkg/config"
|
||||||
|
"code.vikunja.io/api/pkg/notifications"
|
||||||
|
"code.vikunja.io/api/pkg/user"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetThreadID(t *testing.T) {
|
func TestGetThreadID(t *testing.T) {
|
||||||
|
|
@ -92,3 +97,70 @@ func TestGetThreadID(t *testing.T) {
|
||||||
assert.Equal(t, "<task-444@example.com>", threadID)
|
assert.Equal(t, "<task-444@example.com>", threadID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUndoneTasksOverdueNotification_TitleIsMarkdownEscaped(t *testing.T) {
|
||||||
|
originalPublicURL := config.ServicePublicURL.GetString()
|
||||||
|
t.Cleanup(func() { config.ServicePublicURL.Set(originalPublicURL) })
|
||||||
|
config.ServicePublicURL.Set("https://vikunja.example.com/")
|
||||||
|
|
||||||
|
maliciousTitle := "bad](https://evil.com) [click here"
|
||||||
|
n := &UndoneTasksOverdueNotification{
|
||||||
|
User: &user.User{ID: 1, Name: "alice", Username: "alice"},
|
||||||
|
Tasks: map[int64]*Task{
|
||||||
|
42: {ID: 42, Title: maliciousTitle, ProjectID: 7, DueDate: time.Now().Add(-1 * time.Hour)},
|
||||||
|
},
|
||||||
|
Projects: map[int64]*Project{
|
||||||
|
7: {ID: 7, Title: "My Project"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mail := n.ToMail("en")
|
||||||
|
require.NotNil(t, mail)
|
||||||
|
|
||||||
|
opts, err := notifications.RenderMail(mail, "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The rendered HTML must NOT contain an anchor pointing at evil.com —
|
||||||
|
// that would be a successful injection. The literal string "evil.com"
|
||||||
|
// may still appear inside the link text because the malicious title is
|
||||||
|
// rendered verbatim; that is harmless as long as it is not an active
|
||||||
|
// link/image.
|
||||||
|
assert.NotContains(t, opts.HTMLMessage, `href="https://evil.com`,
|
||||||
|
"malicious URL must not be rendered as an anchor")
|
||||||
|
assert.NotContains(t, opts.HTMLMessage, `src="https://evil.com`,
|
||||||
|
"malicious URL must not be rendered as an image")
|
||||||
|
assert.Contains(t, opts.HTMLMessage, "https://vikunja.example.com/tasks/42",
|
||||||
|
"legitimate task link must still render")
|
||||||
|
// Exactly one anchor to the task — the injection must not create a
|
||||||
|
// second one. Vikunja templates also render the Action link, but this
|
||||||
|
// notification has no Action for the individual task.
|
||||||
|
assert.Equal(t, 1, strings.Count(opts.HTMLMessage, `<a href="https://vikunja.example.com/tasks/42`),
|
||||||
|
"expected exactly one anchor to the task")
|
||||||
|
// The malicious title text must still be displayed as literal text
|
||||||
|
// (goldmark will render the backslash escapes as the original characters).
|
||||||
|
assert.Contains(t, opts.HTMLMessage, "bad](https://evil.com) [click here",
|
||||||
|
"malicious title must render as literal text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderDueNotification_TitleIsMarkdownEscaped(t *testing.T) {
|
||||||
|
originalPublicURL := config.ServicePublicURL.GetString()
|
||||||
|
t.Cleanup(func() { config.ServicePublicURL.Set(originalPublicURL) })
|
||||||
|
config.ServicePublicURL.Set("https://vikunja.example.com/")
|
||||||
|
|
||||||
|
n := &ReminderDueNotification{
|
||||||
|
User: &user.User{ID: 1, Name: "alice"},
|
||||||
|
Task: &Task{ID: 99, Title: ""},
|
||||||
|
Project: &Project{ID: 1, Title: "proj"},
|
||||||
|
}
|
||||||
|
|
||||||
|
mail := n.ToMail("en")
|
||||||
|
opts, err := notifications.RenderMail(mail, "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The injection must not create an <img> tag. The literal title
|
||||||
|
// characters may still be present as escaped text.
|
||||||
|
assert.NotContains(t, opts.HTMLMessage, "<img src=\"https://evil.com",
|
||||||
|
"tracking pixel must not render as an <img> tag")
|
||||||
|
assert.NotContains(t, opts.HTMLMessage, `href="https://evil.com`,
|
||||||
|
"tracking URL must not render as an anchor")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ func (n *MigrationFailedNotification) ToMail(lang string) *notifications.Mail {
|
||||||
Subject(i18n.T(lang, "notifications.migration.failed.subject", kind)).
|
Subject(i18n.T(lang, "notifications.migration.failed.subject", kind)).
|
||||||
Line(i18n.T(lang, "notifications.migration.failed.message", kind)).
|
Line(i18n.T(lang, "notifications.migration.failed.message", kind)).
|
||||||
Line(i18n.T(lang, "notifications.migration.failed.retry", kind)).
|
Line(i18n.T(lang, "notifications.migration.failed.retry", kind)).
|
||||||
Line(i18n.T(lang, "notifications.migration.failed.error", n.Error.Error())).
|
Line(i18n.T(lang, "notifications.migration.failed.error", notifications.EscapeMarkdown(n.Error.Error()))).
|
||||||
Line(i18n.T(lang, "notifications.migration.failed.report"))
|
Line(i18n.T(lang, "notifications.migration.failed.report"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package notifications
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// markdownSpecialChars is the full CommonMark §2.4 backslash-escapable set.
|
||||||
|
// '<' is included to neutralize autolinks (`<https://evil.com>`), which
|
||||||
|
// bluemonday's UGC policy would otherwise render as clickable <a> tags.
|
||||||
|
// '\' is handled separately first so inserted backslashes are not re-escaped.
|
||||||
|
const markdownSpecialChars = "`*_{}[]()<>#+-.!|~"
|
||||||
|
|
||||||
|
// EscapeMarkdown escapes every CommonMark-special character in s. Fixes
|
||||||
|
// GHSA-45q4-x4r9-8fqj (Markdown injection in notification emails).
|
||||||
|
func EscapeMarkdown(s string) string {
|
||||||
|
// Backslash first so inserted backslashes are not double-escaped.
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 128 && strings.ContainsRune(markdownSpecialChars, r) {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEscapeMarkdown(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"plain ASCII", "Hello World", "Hello World"},
|
||||||
|
{"backslash", `a\b`, `a\\b`},
|
||||||
|
{"link open bracket", "a[b", `a\[b`},
|
||||||
|
{"link close bracket", "a]b", `a\]b`},
|
||||||
|
{"paren open", "a(b", `a\(b`},
|
||||||
|
{"paren close", "a)b", `a\)b`},
|
||||||
|
{"image bang", "a!b", `a\!b`},
|
||||||
|
{"emphasis asterisk", "a*b", `a\*b`},
|
||||||
|
{"emphasis underscore", "a_b", `a\_b`},
|
||||||
|
{"code backtick", "a`b", "a\\`b"},
|
||||||
|
{"heading hash", "a#b", `a\#b`},
|
||||||
|
{"blockquote", "a>b", `a\>b`},
|
||||||
|
{"list dash", "a-b", `a\-b`},
|
||||||
|
{"list plus", "a+b", `a\+b`},
|
||||||
|
{"list dot", "a.b", `a\.b`},
|
||||||
|
{"pipe (tables)", "a|b", `a\|b`},
|
||||||
|
{"tilde (strikethrough)", "a~b", `a\~b`},
|
||||||
|
{"curly brace open", "a{b", `a\{b`},
|
||||||
|
{"curly brace close", "a}b", `a\}b`},
|
||||||
|
{"angle bracket open", "a<b", `a\<b`},
|
||||||
|
{"advisory PoC: fake link", "test](https://evil.com) [Click to verify", `test\]\(https://evil\.com\) \[Click to verify`},
|
||||||
|
{"advisory PoC: tracking pixel", "", `\!\[\]\(https://evil\.com/track\.png\)`},
|
||||||
|
{"commonmark autolink", "<https://evil.com>", `\<https://evil\.com\>`},
|
||||||
|
{"raw html tag", `<a href="https://evil.com">x</a>`, `\<a href="https://evil\.com"\>x\</a\>`},
|
||||||
|
{"raw img tag", `<img src=x onerror=alert(1)>`, `\<img src=x onerror=alert\(1\)\>`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := EscapeMarkdown(tt.in)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscapeMarkdown_RoundTripThroughGoldmark verifies that every escaped
|
||||||
|
// string, when fed into goldmark as the text portion of a Markdown link,
|
||||||
|
// renders as the original literal text and does NOT produce any additional
|
||||||
|
// <a> or <img> elements from injected Markdown syntax.
|
||||||
|
func TestEscapeMarkdown_RoundTripThroughGoldmark(t *testing.T) {
|
||||||
|
payloads := []string{
|
||||||
|
"test](https://evil.com) [Click to verify",
|
||||||
|
"",
|
||||||
|
"plain title",
|
||||||
|
`a\b`,
|
||||||
|
"`code`",
|
||||||
|
"*bold*",
|
||||||
|
"<https://evil.com>",
|
||||||
|
`<a href="https://evil.com">click</a>`,
|
||||||
|
`<img src=x onerror=alert(1)>`,
|
||||||
|
}
|
||||||
|
for _, p := range payloads {
|
||||||
|
t.Run(p, func(t *testing.T) {
|
||||||
|
// Embed in a markdown link and a free paragraph.
|
||||||
|
md := "* [" + EscapeMarkdown(p) + "](https://vikunja.io/safe)\n\n" + EscapeMarkdown(p)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, goldmark.Convert([]byte(md), &buf))
|
||||||
|
html := buf.String()
|
||||||
|
// There must be exactly one <a href=, the safe one.
|
||||||
|
assert.Equal(t, 1, bytes.Count([]byte(html), []byte("<a href=")),
|
||||||
|
"goldmark output for %q must contain exactly one <a href=: %s", p, html)
|
||||||
|
// There must be zero <img tags — payloads that try to inject images must be escaped.
|
||||||
|
assert.NotContains(t, html, "<img ", "goldmark output for %q must not contain an <img> tag: %s", p, html)
|
||||||
|
// The safe URL must still be present.
|
||||||
|
assert.Contains(t, html, `href="https://vikunja.io/safe"`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue