mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: markdown rendering improvements
Improvements to markdown rendering in notification emails: - More consistent escaping of values interpolated into notification templates - Stricter link handling in the notification email renderer, scoped to the notification rendering path - HTML escaping of values interpolated into the outer email template - Expanded unit and end-to-end coverage of the notification rendering pipeline - `make gen` run to regenerate golden files for SMTP and webhook notification templates
This commit is contained in:
@@ -66,7 +66,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
|
||||
return nil, xerrors.Errorf("render subject: %w", err)
|
||||
}
|
||||
|
||||
htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
|
||||
htmlBody := markdown.HTMLFromMarkdownSafe(bodyTmpl)
|
||||
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("render plaintext body: %w", err)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ .Labels._subject }}</title>
|
||||
<title>{{ .Labels._subject | html }}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617; background: #f8fafc;">
|
||||
<div style="max-width: 600px; margin: 20px auto; padding: 60px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-align: left; font-size: 14px; line-height: 1.5;">
|
||||
@@ -11,16 +11,16 @@
|
||||
<img src="{{ logo_url | html }}" alt="{{ app_name | html }} Logo" style="height: 40px;" />
|
||||
</div>
|
||||
<h1 style="text-align: center; font-size: 24px; font-weight: 400; margin: 8px 0 32px; line-height: 1.5;">
|
||||
{{ .Labels._subject }}
|
||||
{{ .Labels._subject | html }}
|
||||
</h1>
|
||||
<div style="line-height: 1.5;">
|
||||
<p>Hi {{ .UserName }},</p>
|
||||
<p>Hi {{ .UserName | html }},</p>
|
||||
{{ .Labels._body }}
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 32px;">
|
||||
{{ range $action := .Actions }}
|
||||
<a href="{{ $action.URL }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
|
||||
{{ $action.Label }}
|
||||
<a href="{{ $action.URL | html }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
|
||||
{{ $action.Label | html }}
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
markdown "github.com/coder/coder/v2/coderd/render"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/notifications/render"
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
)
|
||||
@@ -43,6 +45,354 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
|
||||
require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered")
|
||||
}
|
||||
|
||||
// TestSMTPHTMLTemplateMarkdownInjection is an end-to-end regression test that
|
||||
// exercises the full notification email rendering pipeline with malicious input.
|
||||
// It simulates the complete flow: SanitizePayload -> GoTemplate (body) ->
|
||||
// HTMLFromMarkdownSafe -> GoTemplate (outer HTML template) and asserts that the
|
||||
// final HTML email output contains no attacker-controlled links, headings, images,
|
||||
// scripts, or other injected content.
|
||||
//
|
||||
// If this test fails, it likely means a change to the rendering pipeline has
|
||||
// introduced a Markdown or HTML injection vulnerability in notification emails.
|
||||
func TestSMTPHTMLTemplateMarkdownInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
helpers := map[string]any{
|
||||
"base_url": func() string { return "https://coder.example.com" },
|
||||
"current_year": func() string { return "2026" },
|
||||
"logo_url": func() string { return "https://coder.example.com/logo.png" },
|
||||
"app_name": func() string { return "Coder" },
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// payload is the unsanitized notification payload (as it would arrive
|
||||
// from the database before SanitizePayload runs).
|
||||
payload types.MessagePayload
|
||||
// bodyTemplate is the notification body template (stored in the DB).
|
||||
bodyTemplate string
|
||||
// absentInHTML lists substrings that must NOT appear in the final HTML
|
||||
// email output.
|
||||
absentInHTML []string
|
||||
// presentInHTML lists substrings that MUST appear in the final HTML.
|
||||
presentInHTML []string
|
||||
}{
|
||||
{
|
||||
name: "markdown link injection via display name",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "Eve\n[Re-authenticate](https://evil.example/login)",
|
||||
Labels: map[string]string{
|
||||
"created_account_user_name": "Eve\n[Re-authenticate](https://evil.example/login)",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
|
||||
absentInHTML: []string{
|
||||
`<a href="https://evil.example`,
|
||||
`href="https://evil.example`,
|
||||
},
|
||||
presentInHTML: []string{
|
||||
"<strong>admin</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "heading injection via display name",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "Eve\n## URGENT SECURITY ALERT",
|
||||
Labels: map[string]string{
|
||||
"suspended_account_user_name": "Eve\n## URGENT SECURITY ALERT",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Account **{{.Labels.suspended_account_user_name}}** suspended by **{{.Labels.initiator}}**.`,
|
||||
absentInHTML: []string{
|
||||
"<h2>",
|
||||
"<h1>",
|
||||
"<h3>",
|
||||
},
|
||||
presentInHTML: []string{
|
||||
"## URGENT SECURITY ALERT", // rendered as literal text
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image tracking pixel injection",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "",
|
||||
Labels: map[string]string{
|
||||
"name": "",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Workspace **{{.Labels.name}}** deleted.`,
|
||||
absentInHTML: []string{
|
||||
`src="https://evil.example`, // no attacker-controlled image source
|
||||
},
|
||||
presentInHTML: []string{
|
||||
"![]", // rendered as literal text
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "javascript URI via markdown link",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "normal",
|
||||
Labels: map[string]string{
|
||||
"name": "[xss](javascript:alert(document.cookie))",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Workspace **{{.Labels.name}}** updated.`,
|
||||
absentInHTML: []string{
|
||||
`href="javascript:`, // no javascript link
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "raw HTML injection in display name",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: `<script>alert(1)</script>`,
|
||||
Labels: map[string]string{},
|
||||
},
|
||||
bodyTemplate: `Hello.`,
|
||||
absentInHTML: []string{
|
||||
"<script>", // raw script tag must not appear
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare URL autolink in label value",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "admin",
|
||||
Labels: map[string]string{
|
||||
"name": "workspace https://evil.example test",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Workspace **{{.Labels.name}}** created.`,
|
||||
absentInHTML: []string{
|
||||
`<a href="https://evil.example"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression: CommonMark angle-bracket autolink via display name.
|
||||
// Escaping ">" alone did not stop this ("<" was never escaped).
|
||||
name: "angle-bracket autolink via display name",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "<https://evil.example/login>",
|
||||
Labels: map[string]string{
|
||||
"created_account_user_name": "<https://evil.example/login>",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
|
||||
absentInHTML: []string{
|
||||
`href="https://evil.example`,
|
||||
},
|
||||
presentInHTML: []string{
|
||||
"<strong>admin</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression: the backslash-tweaked bypass of the angle-bracket
|
||||
// autolink escape. A user backslash combined with the sanitizer's
|
||||
// "\>" left a live autolink terminator until "\" was also escaped.
|
||||
name: "backslash-tweaked autolink bypass via display name",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "<https://evil.example/login\\>",
|
||||
Labels: map[string]string{
|
||||
"created_account_user_name": "<https://evil.example/login\\>",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
|
||||
absentInHTML: []string{
|
||||
`href="https://evil.example`,
|
||||
},
|
||||
presentInHTML: []string{
|
||||
"<strong>admin</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legitimate template content renders correctly",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "John Smith",
|
||||
Labels: map[string]string{
|
||||
"workspace": "my-dev-env",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Workspace **{{.Labels.workspace}}** was updated by **{{.Labels.initiator}}**.`,
|
||||
presentInHTML: []string{
|
||||
"Hi John Smith,",
|
||||
"<strong>my-dev-env</strong>",
|
||||
"<strong>admin</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unicode display name preserved",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "Jos\u00e9 Garc\u00eda",
|
||||
Labels: map[string]string{"name": "workspace-1"},
|
||||
},
|
||||
bodyTemplate: `Workspace **{{.Labels.name}}** ready.`,
|
||||
presentInHTML: []string{
|
||||
"Jos\u00e9 Garc\u00eda",
|
||||
"<strong>workspace-1</strong>",
|
||||
},
|
||||
},
|
||||
// Legitimate rendering regression tests below. These verify that
|
||||
// production notification features continue working after the
|
||||
// security hardening.
|
||||
{
|
||||
name: "template-authored markdown link renders correctly",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "user",
|
||||
Labels: map[string]string{
|
||||
"name": "my-workspace",
|
||||
"reason": "inactivity",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.`,
|
||||
presentInHTML: []string{
|
||||
`<a href="https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise">`,
|
||||
"<strong>dormant</strong>",
|
||||
"<strong>my-workspace</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markdown list renders correctly",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "admin",
|
||||
Labels: map[string]string{},
|
||||
},
|
||||
bodyTemplate: "Build failures:\n\n- **template-v1** failed 3 times\n- **template-v2** failed 1 time",
|
||||
presentInHTML: []string{
|
||||
"<li>",
|
||||
"<strong>template-v1</strong>",
|
||||
"<strong>template-v2</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "emphasis and code spans render correctly",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "admin",
|
||||
Labels: map[string]string{
|
||||
"workspace": "my-ws",
|
||||
},
|
||||
},
|
||||
bodyTemplate: "Volume **`/home/coder`** is over 90%% full in workspace **{{.Labels.workspace}}**.",
|
||||
presentInHTML: []string{
|
||||
"<code>/home/coder</code>",
|
||||
"<strong>my-ws</strong>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "action buttons render with proper URLs",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "user",
|
||||
Labels: map[string]string{},
|
||||
Actions: []types.TemplateAction{
|
||||
{Label: "View workspace", URL: "https://coder.example.com/@user/my-workspace"},
|
||||
{Label: "View template", URL: "https://coder.example.com/templates/docker"},
|
||||
},
|
||||
},
|
||||
bodyTemplate: `Your workspace is ready.`,
|
||||
presentInHTML: []string{
|
||||
`href="https://coder.example.com/@user/my-workspace"`,
|
||||
"View workspace",
|
||||
`href="https://coder.example.com/templates/docker"`,
|
||||
"View template",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "footer links and logo render correctly",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "user",
|
||||
Labels: map[string]string{},
|
||||
},
|
||||
bodyTemplate: `Hello.`,
|
||||
presentInHTML: []string{
|
||||
// Footer links
|
||||
`href="https://coder.example.com/settings/notifications"`,
|
||||
"manage your notification settings",
|
||||
"Stop receiving emails like this",
|
||||
// Logo
|
||||
`<img src="https://coder.example.com/logo.png"`,
|
||||
// Copyright
|
||||
"Coder. All rights reserved",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conditional label rendering",
|
||||
payload: types.MessagePayload{
|
||||
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
|
||||
UserName: "admin",
|
||||
Labels: map[string]string{
|
||||
"created_account_name": "newuser",
|
||||
"created_account_user_name": "New User",
|
||||
"initiator": "admin",
|
||||
},
|
||||
},
|
||||
bodyTemplate: `New user account **{{.Labels.created_account_name}}** has been created.` + "\n\n" +
|
||||
`This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.`,
|
||||
presentInHTML: []string{
|
||||
"<strong>newuser</strong>",
|
||||
"<strong>New User</strong>",
|
||||
"<strong>admin</strong>",
|
||||
"for",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Step 1: Render the body from a sanitized copy, leaving the
|
||||
// payload itself raw (as notifier.prepare does).
|
||||
sanitized := render.SanitizedPayload(tt.payload)
|
||||
|
||||
// Step 2: Render body template via GoTemplate
|
||||
body, err := render.GoTemplate(tt.bodyTemplate, sanitized, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 3: Convert markdown body to HTML (as SMTP dispatcher does)
|
||||
htmlBody := markdown.HTMLFromMarkdownSafe(body)
|
||||
|
||||
// Step 4: Render subject via PlaintextFromMarkdown
|
||||
subject := "Test notification"
|
||||
|
||||
// Step 5: Inject into outer HTML template using the raw payload (as
|
||||
// the SMTP dispatcher does). The greeting interpolates the raw
|
||||
// UserName, made safe by the template's `| html`, not by Markdown
|
||||
// escaping.
|
||||
tt.payload.Labels["_subject"] = subject
|
||||
tt.payload.Labels["_body"] = htmlBody
|
||||
finalHTML, err := render.GoTemplate(htmlTemplate, tt.payload, helpers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 6: Assert on final HTML output
|
||||
for _, s := range tt.absentInHTML {
|
||||
require.False(t, strings.Contains(finalHTML, s),
|
||||
"final HTML email must NOT contain %q", s)
|
||||
}
|
||||
for _, s := range tt.presentInHTML {
|
||||
require.True(t, strings.Contains(finalHTML, s),
|
||||
"final HTML email must contain %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFromAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -250,11 +250,20 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification
|
||||
return nil, decorateHelpersError{err}
|
||||
}
|
||||
|
||||
// Render the title and body from a sanitized copy so that user-controlled
|
||||
// values (display names from OIDC claims, label values) cannot inject
|
||||
// Markdown into the contexts that are subsequently Markdown-rendered: the
|
||||
// SMTP HTML email and the react-markdown inbox. The original payload is
|
||||
// passed to the dispatcher unmodified so that non-Markdown consumers (the
|
||||
// webhook JSON, the SMTP greeting, and the plaintext email part) receive
|
||||
// verbatim values rather than escaped ones.
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
var title, body string
|
||||
if title, err = render.GoTemplate(msg.TitleTemplate, payload, helpers); err != nil {
|
||||
if title, err = render.GoTemplate(msg.TitleTemplate, sanitized, helpers); err != nil {
|
||||
return nil, xerrors.Errorf("render title: %w", err)
|
||||
}
|
||||
if body, err = render.GoTemplate(msg.BodyTemplate, payload, helpers); err != nil {
|
||||
if body, err = render.GoTemplate(msg.BodyTemplate, sanitized, helpers); err != nil {
|
||||
return nil, xerrors.Errorf("render body: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,70 @@ import (
|
||||
// This string is not exported as a const from the text/template.
|
||||
const NoValue = "<no value>"
|
||||
|
||||
// markdownReplacer escapes characters that have special meaning in Markdown.
|
||||
// This prevents user-controlled values (display names, labels) from being
|
||||
// interpreted as Markdown syntax when interpolated into notification body
|
||||
// templates that are subsequently rendered as HTML.
|
||||
var markdownReplacer = strings.NewReplacer(
|
||||
// Escape the backslash first (conceptually): strings.NewReplacer performs a
|
||||
// single non-overlapping left-to-right pass and never re-processes its own
|
||||
// output, so a user-supplied "\" becomes a literal "\\" without touching the
|
||||
// backslashes we introduce below.
|
||||
"\\", "\\\\",
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"#", "\\#",
|
||||
"!", "\\!",
|
||||
"*", "\\*",
|
||||
// Escape both angle brackets. ">" alone left "<...>" CommonMark autolinks
|
||||
// exploitable (angle-bracket autolinks are core inline syntax, not the
|
||||
// parser.Autolink extension, so disabling that extension does not stop
|
||||
// them); escaping "<" prevents such an autolink from ever opening.
|
||||
"<", "\\<",
|
||||
">", "\\>",
|
||||
"~", "\\~",
|
||||
"`", "\\`",
|
||||
"|", "\\|",
|
||||
"_", "\\_",
|
||||
"\n", " ",
|
||||
"\r", "",
|
||||
)
|
||||
|
||||
// SanitizeMarkdown escapes Markdown metacharacters in a string and
|
||||
// collapses newlines so that the value is rendered as literal text
|
||||
// when embedded in a Markdown document.
|
||||
func SanitizeMarkdown(s string) string {
|
||||
return markdownReplacer.Replace(s)
|
||||
}
|
||||
|
||||
// SanitizedPayload returns a copy of p with Markdown metacharacters escaped in
|
||||
// the user-controlled fields (UserName and Labels). Use the returned copy only
|
||||
// to render the title and body templates, whose output is subsequently passed
|
||||
// through a Markdown renderer (HTMLFromMarkdownSafe for the SMTP HTML email, and
|
||||
// react-markdown for the in-product inbox). Escaping there prevents display
|
||||
// names and label values from injecting Markdown such as links or headings.
|
||||
//
|
||||
// The original payload is left unmodified. Non-Markdown consumers must receive
|
||||
// the verbatim values: the webhook delivers payload.Labels and payload.UserName
|
||||
// as raw JSON, the SMTP greeting interpolates UserName as HTML (escaped by the
|
||||
// template's `| html`, not by Markdown escaping), and the plaintext email part
|
||||
// is not Markdown. Escaping those in place left literal backslashes in every one
|
||||
// of them.
|
||||
func SanitizedPayload(p types.MessagePayload) types.MessagePayload {
|
||||
sanitized := p
|
||||
sanitized.UserName = SanitizeMarkdown(p.UserName)
|
||||
if p.Labels != nil {
|
||||
// Copy the map so the caller's payload keeps its raw label values.
|
||||
sanitized.Labels = make(map[string]string, len(p.Labels))
|
||||
for k, v := range p.Labels {
|
||||
sanitized.Labels[k] = SanitizeMarkdown(v)
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// GoTemplate attempts to substitute the given payload into the given template using Go's templating syntax.
|
||||
// TODO: memoize templates for memory efficiency?
|
||||
func GoTemplate(in string, payload types.MessagePayload, extraFuncs template.FuncMap) (string, error) {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package render_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/notifications/render"
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
coderrender "github.com/coder/coder/v2/coderd/render"
|
||||
)
|
||||
|
||||
func TestSanitizeMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
contains []string // substrings the output must contain
|
||||
absent []string // substrings the output must NOT contain
|
||||
exact string // exact expected output, empty to skip
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "Hello World",
|
||||
exact: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
exact: "",
|
||||
},
|
||||
{
|
||||
name: "unicode names preserved",
|
||||
input: "José García",
|
||||
exact: "José García",
|
||||
},
|
||||
{
|
||||
name: "CJK names preserved",
|
||||
input: "田中太郎",
|
||||
exact: "田中太郎",
|
||||
},
|
||||
{
|
||||
name: "markdown link escaped",
|
||||
input: "[Click me](https://evil.example)",
|
||||
absent: []string{"[Click me]"},
|
||||
contains: []string{"\\[", "\\]", "\\(", "\\)"},
|
||||
},
|
||||
{
|
||||
name: "heading injection escaped",
|
||||
input: "## URGENT heading",
|
||||
absent: []string{"## "},
|
||||
contains: []string{"\\#\\# URGENT heading"},
|
||||
},
|
||||
{
|
||||
name: "image injection escaped",
|
||||
input: "",
|
||||
absent: []string{"",
|
||||
absent: []string{"\n", "[Re-authenticate now]"},
|
||||
contains: []string{"Eve ", "\\#\\# URGENT"},
|
||||
},
|
||||
{
|
||||
name: "javascript URI in markdown link",
|
||||
input: "[Click](javascript:alert(1))",
|
||||
absent: []string{"[Click](javascript"},
|
||||
contains: []string{"\\[Click\\]\\(javascript"},
|
||||
},
|
||||
{
|
||||
name: "parentheses in normal name",
|
||||
input: "Jane (Admin)",
|
||||
exact: "Jane \\(Admin\\)",
|
||||
},
|
||||
{
|
||||
// Regression: angle-bracket autolink must not survive. Escaping ">"
|
||||
// alone was insufficient because "<" was never escaped, so the
|
||||
// "<...>" autolink still opened (SEC-93 / GHSA-2w2x-w3c8-9jrw
|
||||
// follow-up). The escaped output still contains the "<" byte (as
|
||||
// "\<"), so correctness is asserted via the exact escaped form here;
|
||||
// the "no clickable <a href>" guarantee is covered end-to-end in
|
||||
// TestSMTPHTMLTemplateMarkdownInjection.
|
||||
name: "angle-bracket autolink escaped",
|
||||
input: "<https://evil.example/login>",
|
||||
exact: "\\<https://evil.example/login\\>",
|
||||
},
|
||||
{
|
||||
// Regression: the backslash-tweaked bypass. Without escaping "\",
|
||||
// a user backslash combined with the sanitizer's own "\>" to leave
|
||||
// a live autolink terminator.
|
||||
name: "backslash bypass of autolink escaped",
|
||||
input: "<https://evil.example/login\\>",
|
||||
exact: "\\<https://evil.example/login\\\\\\>",
|
||||
},
|
||||
{
|
||||
name: "lone backslash escaped",
|
||||
input: "back\\slash",
|
||||
exact: "back\\\\slash",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := render.SanitizeMarkdown(tt.input)
|
||||
|
||||
if tt.exact != "" {
|
||||
assert.Equal(t, tt.exact, result)
|
||||
}
|
||||
for _, s := range tt.contains {
|
||||
assert.Contains(t, result, s, "output should contain %q", s)
|
||||
}
|
||||
for _, s := range tt.absent {
|
||||
assert.NotContains(t, result, s, "output should NOT contain %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizedPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("sanitizes UserName and Labels in the copy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
UserName: "[evil](https://evil.example)",
|
||||
UserEmail: "user@example.com",
|
||||
UserUsername: "normaluser",
|
||||
Labels: map[string]string{
|
||||
"initiator": "admin",
|
||||
"created_account_user_name": "## Heading\n[link](https://evil.example)",
|
||||
"safe_label": "no-special-chars",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
// UserName should be sanitized in the copy
|
||||
assert.NotContains(t, sanitized.UserName, "[evil]")
|
||||
assert.Contains(t, sanitized.UserName, "\\[evil\\]")
|
||||
|
||||
// Labels should be sanitized in the copy
|
||||
assert.NotContains(t, sanitized.Labels["created_account_user_name"], "## Heading")
|
||||
assert.Contains(t, sanitized.Labels["created_account_user_name"], "\\#\\# Heading")
|
||||
assert.NotContains(t, sanitized.Labels["created_account_user_name"], "\n")
|
||||
|
||||
// Non-special labels unchanged
|
||||
assert.Equal(t, "no-special-chars", sanitized.Labels["safe_label"])
|
||||
assert.Equal(t, "admin", sanitized.Labels["initiator"])
|
||||
|
||||
// Fields NOT in scope are NOT modified
|
||||
assert.Equal(t, "user@example.com", sanitized.UserEmail)
|
||||
assert.Equal(t, "normaluser", sanitized.UserUsername)
|
||||
})
|
||||
|
||||
t.Run("leaves the original payload untouched", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The original must keep its raw values so non-Markdown consumers
|
||||
// (webhook JSON, SMTP greeting, plaintext) receive them verbatim.
|
||||
payload := types.MessagePayload{
|
||||
UserName: "Jane (Admin)",
|
||||
Labels: map[string]string{
|
||||
"template_version": "angry_torvalds",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
// Copy is escaped.
|
||||
assert.Equal(t, "Jane \\(Admin\\)", sanitized.UserName)
|
||||
assert.Equal(t, "angry\\_torvalds", sanitized.Labels["template_version"])
|
||||
|
||||
// Original is verbatim.
|
||||
assert.Equal(t, "Jane (Admin)", payload.UserName)
|
||||
assert.Equal(t, "angry_torvalds", payload.Labels["template_version"])
|
||||
})
|
||||
|
||||
t.Run("handles nil labels map", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
UserName: "test",
|
||||
Labels: nil,
|
||||
}
|
||||
var sanitized types.MessagePayload
|
||||
require.NotPanics(t, func() {
|
||||
sanitized = render.SanitizedPayload(payload)
|
||||
})
|
||||
assert.Nil(t, sanitized.Labels)
|
||||
})
|
||||
|
||||
t.Run("handles empty payload", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sanitized types.MessagePayload
|
||||
require.NotPanics(t, func() {
|
||||
sanitized = render.SanitizedPayload(types.MessagePayload{})
|
||||
})
|
||||
assert.Equal(t, "", sanitized.UserName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizeMarkdownInTemplateRendering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// End-to-end: sanitize -> GoTemplate -> verify no injection in output
|
||||
t.Run("malicious display name in body template", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
UserName: "Eve\n## URGENT\n[Click](https://evil.example)",
|
||||
Labels: map[string]string{
|
||||
"created_account_name": "eviluser",
|
||||
"created_account_user_name": "Eve\n## URGENT\n[Click](https://evil.example)",
|
||||
"initiator": "admin",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
bodyTmpl := `New user account **{{.Labels.created_account_name}}** has been created.
|
||||
|
||||
This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.`
|
||||
|
||||
body, err := render.GoTemplate(bodyTmpl, sanitized, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The rendered markdown should not contain unescaped link/heading syntax
|
||||
assert.NotContains(t, body, "[Click](")
|
||||
assert.NotContains(t, body, "## URGENT")
|
||||
// Should still contain escaped versions
|
||||
assert.Contains(t, body, "\\[Click\\]")
|
||||
})
|
||||
|
||||
t.Run("bold formatting in template still works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
Labels: map[string]string{
|
||||
"name": "my-workspace",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
body, err := render.GoTemplate(
|
||||
`Workspace **{{.Labels.name}}** has been deleted.`,
|
||||
sanitized, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Template-level bold markers should survive (they're in the template, not labels)
|
||||
assert.Contains(t, body, "**my-workspace**")
|
||||
})
|
||||
|
||||
t.Run("label with asterisks does not break bold", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
Labels: map[string]string{
|
||||
"name": "workspace*test",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
body, err := render.GoTemplate(
|
||||
`Workspace **{{.Labels.name}}** updated.`,
|
||||
sanitized, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The escaped asterisk in the value should not interfere with template bold
|
||||
assert.Contains(t, body, `**workspace\*test**`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTMLFromMarkdownSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Import the render package for HTMLFromMarkdownSafe
|
||||
// We test it indirectly through the notification render package
|
||||
|
||||
t.Run("sanitized payload produces no links in final HTML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := types.MessagePayload{
|
||||
Labels: map[string]string{
|
||||
"name": "[evil](https://evil.example)",
|
||||
},
|
||||
}
|
||||
sanitized := render.SanitizedPayload(payload)
|
||||
|
||||
body, err := render.GoTemplate(
|
||||
`Account **{{.Labels.name}}** created.`,
|
||||
sanitized, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Render the sanitized body to HTML exactly as the SMTP dispatcher
|
||||
// does, and assert the attacker link never becomes an <a> tag. The URL
|
||||
// still appears as inert literal text, which is fine; what matters is
|
||||
// that it is not a clickable link.
|
||||
html := coderrender.HTMLFromMarkdownSafe(body)
|
||||
assert.NotContains(t, html, "<a", "sanitized body must not produce a link")
|
||||
assert.Contains(t, html, "<strong>", "legitimate template formatting should still render")
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-workspace' completed</title>
|
||||
<title>Task 'my-workspace' completed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-workspace' completed
|
||||
Task 'my-workspace' completed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-workspace' failed</title>
|
||||
<title>Task 'my-workspace' failed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-workspace' failed
|
||||
Task 'my-workspace' failed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-workspace' is idle</title>
|
||||
<title>Task 'my-workspace' is idle</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-workspace' is idle
|
||||
Task 'my-workspace' is idle
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-task' is paused</title>
|
||||
<title>Task 'my-task' is paused</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-task' is paused
|
||||
Task 'my-task' is paused
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-task' has resumed</title>
|
||||
<title>Task 'my-task' has resumed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-task' has resumed
|
||||
Task 'my-task' has resumed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-workspace' is working</title>
|
||||
<title>Task 'my-workspace' is working</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-workspace' is working
|
||||
Task 'my-workspace' is working
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Template "Bobby's Template" deleted</title>
|
||||
<title>Template "Bobby's Template" deleted</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -42,7 +42,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Template "Bobby's Template" deleted
|
||||
Template "Bobby's Template" deleted
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -35,7 +35,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Template 'alpha' has been deprecated</title>
|
||||
<title>Template 'alpha' has been deprecated</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -50,7 +50,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Template 'alpha' has been deprecated
|
||||
Template 'alpha' has been deprecated
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>User account "bobby" activated</title>
|
||||
<title>User account "bobby" activated</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
User account "bobby" activated
|
||||
User account "bobby" activated
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>User account "bobby" created</title>
|
||||
<title>User account "bobby" created</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
User account "bobby" created
|
||||
User account "bobby" created
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>User account "bobby" deleted</title>
|
||||
<title>User account "bobby" deleted</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
User account "bobby" deleted
|
||||
User account "bobby" deleted
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>User account "bobby" suspended</title>
|
||||
<title>User account "bobby" suspended</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -45,7 +45,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
User account "bobby" suspended
|
||||
User account "bobby" suspended
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+4
-4
@@ -56,10 +56,10 @@ argin: 8px 0 32px; line-height: 1.5;">
|
||||
<div style=3D"text-align: center; margin-top: 32px;">
|
||||
=20
|
||||
<a href=3D"http://test.com/reset-password/change?otp=3Dfad9020b-656=
|
||||
2-4cdb-87f1-0486f1bea415&email=3Dbobby%2Fdrop-table%2Buser%40coder.com" sty=
|
||||
le=3D"display: inline-block; padding: 13px 24px; background-color: #020617;=
|
||||
color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;"=
|
||||
>
|
||||
2-4cdb-87f1-0486f1bea415&email=3Dbobby%2Fdrop-table%2Buser%40coder.com"=
|
||||
style=3D"display: inline-block; padding: 13px 24px; background-color: #020=
|
||||
617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4=
|
||||
px;">
|
||||
Reset password
|
||||
</a>
|
||||
=20
|
||||
|
||||
Vendored
+3
-2
@@ -30,7 +30,8 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" updated automatically</title>
|
||||
<title>Workspace "bobby-workspace" updated automatically</title=
|
||||
>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -45,7 +46,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" updated automatically
|
||||
Workspace "bobby-workspace" updated automatically
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" autobuild failed</title>
|
||||
<title>Workspace "bobby-workspace" autobuild failed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" autobuild failed
|
||||
Workspace "bobby-workspace" autobuild failed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your workspace "bobby-workspace" will stop soon</title>
|
||||
<title>Your workspace "bobby-workspace" will stop soon</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your workspace "bobby-workspace" will stop soon
|
||||
Your workspace "bobby-workspace" will stop soon
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace 'bobby-workspace' has been created</title>
|
||||
<title>Workspace 'bobby-workspace' has been created</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -43,7 +43,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace 'bobby-workspace' has been created
|
||||
Workspace 'bobby-workspace' has been created
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" deleted</title>
|
||||
<title>Workspace "bobby-workspace" deleted</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +46,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" deleted
|
||||
Workspace "bobby-workspace" deleted
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" deleted</title>
|
||||
<title>Workspace "bobby-workspace" deleted</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +46,7 @@ ication Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" deleted
|
||||
Workspace "bobby-workspace" deleted
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" marked as dormant</title>
|
||||
<title>Workspace "bobby-workspace" marked as dormant</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -49,7 +49,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" marked as dormant
|
||||
Workspace "bobby-workspace" marked as dormant
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" marked as dormant</title>
|
||||
<title>Workspace "bobby-workspace" marked as dormant</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +46,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" marked as dormant
|
||||
Workspace "bobby-workspace" marked as dormant
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden
Vendored
+2
-2
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" manual build failed</title>
|
||||
<title>Workspace "bobby-workspace" manual build failed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -44,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" manual build failed
|
||||
Workspace "bobby-workspace" manual build failed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+3
-2
@@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace 'bobby-workspace' has been manually updated</title>
|
||||
<title>Workspace 'bobby-workspace' has been manually updated</t=
|
||||
itle>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace 'bobby-workspace' has been manually updated
|
||||
Workspace 'bobby-workspace' has been manually updated
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden
Vendored
+2
-2
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Workspace "bobby-workspace" marked for deletion</title>
|
||||
<title>Workspace "bobby-workspace" marked for deletion</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +46,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Workspace "bobby-workspace" marked for deletion
|
||||
Workspace "bobby-workspace" marked for deletion
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+3
-2
@@ -27,7 +27,8 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your workspace "bobby-workspace" is low on volume space</title>
|
||||
<title>Your workspace "bobby-workspace" is low on volume space<=
|
||||
/title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -42,7 +43,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your workspace "bobby-workspace" is low on volume space
|
||||
Your workspace "bobby-workspace" is low on volume space
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
+3
-2
@@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your workspace "bobby-workspace" is low on volume space</title>
|
||||
<title>Your workspace "bobby-workspace" is low on volume space<=
|
||||
/title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your workspace "bobby-workspace" is low on volume space
|
||||
Your workspace "bobby-workspace" is low on volume space
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+3
-2
@@ -28,7 +28,8 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your workspace "bobby-workspace" is low on memory</title>
|
||||
<title>Your workspace "bobby-workspace" is low on memory</title=
|
||||
>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -43,7 +44,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your workspace "bobby-workspace" is low on memory
|
||||
Your workspace "bobby-workspace" is low on memory
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your account "bobby" has been activated</title>
|
||||
<title>Your account "bobby" has been activated</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -42,7 +42,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your account "bobby" has been activated
|
||||
Your account "bobby" has been activated
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
Vendored
+2
-2
@@ -25,7 +25,7 @@ Content-Type: text/html; charset=UTF-8
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your account "bobby" has been suspended</title>
|
||||
<title>Your account "bobby" has been suspended</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -40,7 +40,7 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your account "bobby" has been suspended
|
||||
Your account "bobby" has been suspended
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
@@ -114,10 +114,29 @@ func PlaintextFromMarkdown(markdown string) (string, error) {
|
||||
}
|
||||
|
||||
func HTMLFromMarkdown(markdown string) string {
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak.
|
||||
return renderHTMLFromMarkdown(markdown, parser.CommonExtensions|parser.HardLineBreak, html.CommonFlags|html.SkipHTML)
|
||||
}
|
||||
|
||||
// HTMLFromMarkdownSafe renders Markdown to HTML with additional security
|
||||
// hardening for content that may include user-controlled values (e.g.
|
||||
// notification emails): autolinks are disabled so that only explicit Markdown
|
||||
// link syntax produces <a> tags, and Safelink drops links whose scheme is not
|
||||
// http/https/ftp/mailto.
|
||||
//
|
||||
// The hardening is scoped to this function. HTMLFromMarkdown renders
|
||||
// admin-authored deployment text (OIDCConfig.SignupsDisabledText) and keeps the
|
||||
// standard flags so links with custom schemes (e.g. slack://) still render.
|
||||
func HTMLFromMarkdownSafe(markdown string) string {
|
||||
extensions := parser.CommonExtensions | parser.HardLineBreak
|
||||
extensions &^= parser.Autolink
|
||||
return renderHTMLFromMarkdown(markdown, extensions, html.CommonFlags|html.SkipHTML|html.Safelink)
|
||||
}
|
||||
|
||||
func renderHTMLFromMarkdown(markdown string, extensions parser.Extensions, flags html.Flags) string {
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse([]byte(markdown))
|
||||
renderer := html.NewRenderer(html.RendererOptions{
|
||||
Flags: html.CommonFlags | html.SkipHTML,
|
||||
Flags: flags,
|
||||
})
|
||||
return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package render_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -143,3 +144,114 @@ func TestInnerTextFromMarkdown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLFromMarkdownSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
contains []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "explicit https link preserved",
|
||||
input: "[Coder](https://coder.com)",
|
||||
contains: []string{`<a href="https://coder.com">Coder</a>`},
|
||||
},
|
||||
{
|
||||
name: "explicit http link preserved",
|
||||
input: "[Link](http://example.com)",
|
||||
contains: []string{`<a href="http://example.com">Link</a>`},
|
||||
},
|
||||
{
|
||||
name: "javascript URI blocked by Safelink",
|
||||
input: "[Click](javascript:alert(1))",
|
||||
absent: []string{"javascript:", "<a href"},
|
||||
},
|
||||
{
|
||||
name: "data URI blocked by Safelink",
|
||||
input: "[Click](data:text/html,<script>alert(1)</script>)",
|
||||
absent: []string{"data:", "<a href"},
|
||||
},
|
||||
{
|
||||
name: "bare URL NOT auto-linked",
|
||||
input: "Visit https://evil.example for details",
|
||||
absent: []string{"<a href"},
|
||||
contains: []string{"https://evil.example"},
|
||||
},
|
||||
{
|
||||
name: "bold and emphasis still work",
|
||||
input: "**bold** and *italic*",
|
||||
contains: []string{"<strong>bold</strong>", "<em>italic</em>"},
|
||||
},
|
||||
{
|
||||
name: "raw HTML stripped",
|
||||
input: `<script>alert(1)</script>`,
|
||||
absent: []string{"<script>"},
|
||||
},
|
||||
{
|
||||
name: "escaped markdown renders as literal text",
|
||||
input: `\[not a link\]\(https://evil.example\)`,
|
||||
absent: []string{"<a href"},
|
||||
contains: []string{"[not a link]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := render.HTMLFromMarkdownSafe(tt.input)
|
||||
for _, s := range tt.contains {
|
||||
if !strings.Contains(result, s) {
|
||||
t.Errorf("output %q should contain %q", result, s)
|
||||
}
|
||||
}
|
||||
for _, s := range tt.absent {
|
||||
if strings.Contains(result, s) {
|
||||
t.Errorf("output %q should NOT contain %q", result, s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLFromMarkdownSafelink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Safelink is scoped to HTMLFromMarkdownSafe. HTMLFromMarkdown renders
|
||||
// admin-authored deployment text and keeps its pre-existing behavior, so
|
||||
// it does not block or rewrite link schemes.
|
||||
t.Run("HTMLFromMarkdownSafe blocks javascript URI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := render.HTMLFromMarkdownSafe("[Click](javascript:alert(1))")
|
||||
if strings.Contains(result, "javascript:") {
|
||||
t.Error("HTMLFromMarkdownSafe should block javascript: URIs via Safelink")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTMLFromMarkdown does not rewrite custom-scheme links", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Admin-authored deployment text may legitimately use custom schemes.
|
||||
result := render.HTMLFromMarkdown("[Open the app](slack://channel)")
|
||||
if !strings.Contains(result, `href="slack://channel"`) {
|
||||
t.Errorf("HTMLFromMarkdown should render custom-scheme links, got %q", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTMLFromMarkdown allows autolinks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := render.HTMLFromMarkdown("Visit https://coder.com for details")
|
||||
if !strings.Contains(result, "<a href") {
|
||||
t.Error("HTMLFromMarkdown should auto-link bare URLs")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTMLFromMarkdownSafe disables autolinks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := render.HTMLFromMarkdownSafe("Visit https://coder.com for details")
|
||||
if strings.Contains(result, "<a href") {
|
||||
t.Error("HTMLFromMarkdownSafe should NOT auto-link bare URLs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user