fix: prevent markdown injection in notifications (#28340)

First of two PRs.

Notification title and body templates are Markdown authored by Coder,
but the label values interpolated into them are user-controlled and were
substituted through `text/template`, which does no escaping. Those
values arrive from user profile fields and from OIDC/GitHub name claims.

This PR:

- Neutralizes Markdown structure in label, data, and `UserName` values
before they reach the template. Applied in `notifier.prepare`, so
already-queued messages are covered and the stored payload keeps its
original values for webhook consumers. Nested `.Data` map keys are
escaped too: one shipped template prints a key, and those keys are
Terraform resource addresses.
- Narrows the notification Markdown grammar to what templates actually
use. `CommonExtensions` enabled Tables, DefinitionLists and MathJax,
each openable from a value and used by no template. Autolink stays off
so a URL in a value cannot become an anchor.
- Enables `html.Safelink`, restricting generated hrefs to safe schemes,
and guards the panic it exposes: `parser.IsSafeURL` slices a destination
before bounds-checking it, so `[docs]()` crashed both renderers.
- Folds line breaks out of the `Subject:` header and encodes it, fixing
a pre-existing RFC 2047 violation for non-ASCII subjects, a forged
encoded-word that let a value choose the displayed subject, and headers
running past RFC 5322's 998-octet line limit.

Escaping is narrow on purpose, split by where each character carries
meaning:

- `` \[]()!<` `` everywhere. Backtick is in this group because a fenced
block's info string is an HTML sink: gomarkdown writes it into
`class="language-..."` unescaped, and `SkipHTML` does not apply to a
`CodeBlock` node, so a value that closes the attribute and the tag
injects live markup.
- `#-+.>|` only in leading position, so values like `bobby-workspace`
and `1.5` are untouched.
- `=`, `~` and `:` are not escapable by both renderers, so the preceding
line break is folded instead. `:` opens a definition list and a GFM
table delimiter row that escaping `|` cannot reach. A value's *first*
line has no preceding break to fold, so a real tilde fence or `===`
underline is escaped there instead, accepting a visible backslash: an
unterminated `~~~` at the start of a title otherwise renders the
Subject, `<title>` and heading empty.
- Leading indentation is truncated to three spaces. Four open an
indented code block and a space has no escape.
- Emphasis characters are left alone. Escaping `_` corrupts label values
such as `user_override` that body templates compare with `eq`, which
silently drops content from the rendered email.

One golden file changes: the resource replacements `body_markdown` now
reads `docker_container\[0\]`, from the map-key escaping above. Every
other golden is byte-identical.

**One known residual**, pinned by a test that fails if it closes:
CommonMark does not process escapes inside a code span, so where a
template wraps a value in one, as the workspace out-of-disk body does,
the escaper's own backslashes reach the reader. That depends on where
the value lands rather than what it contains, which a pre-render escaper
cannot see. This narrows the class rather than closing it.

**#28397 completes the fix and is stacked on this branch. This PR should
not merge without it.**

Escaping here cannot reach the SMTP HTML template's sinks, by design
rather than by oversight: the subject is produced by
`PlaintextFromMarkdown`, which strips exactly the backslashes added
here, and `html.gotmpl` then interpolated the result through
`text/template`. On this branch alone, a label value still reaches the
Subject, `<title>` and `<h1>` as live markup. #28397 escapes at those
sinks with `| html`, which is the only place the information needed to
escape correctly exists.
This commit is contained in:
Bobby Ho
2026-08-25 08:12:00 -07:00
committed by GitHub
parent be9f8592eb
commit 9e8075db0f
13 changed files with 1489 additions and 7 deletions
+71 -2
View File
@@ -6,7 +6,9 @@ import (
"crypto/tls"
"crypto/x509"
_ "embed"
"encoding/base64"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
@@ -18,6 +20,7 @@ import (
"sync"
"text/template"
"time"
"unicode/utf8"
"github.com/emersion/go-sasl"
smtp "github.com/emersion/go-smtp"
@@ -66,7 +69,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
return nil, xerrors.Errorf("render subject: %w", err)
}
htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
htmlBody := markdown.HTMLFromNotificationMarkdown(bodyTmpl)
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
if err != nil {
return nil, xerrors.Errorf("render plaintext body: %w", err)
@@ -202,7 +205,7 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
multipartWriter := multipart.NewWriter(multipartBuffer)
_, _ = fmt.Fprintf(msg, "From: %s\r\n", headerFrom)
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", "))
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject)
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", encodeHeaderValue(subject))
_, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname())
_, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
_, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
@@ -573,3 +576,69 @@ func (s *SMTPHandler) password() (string, error) {
}
return s.cfg.Auth.Password.String(), nil
}
const (
encodedWordPrefix = "=?utf-8?b?"
encodedWordSuffix = "?="
// RFC 2047 limits an encoded-word to 75 characters including its
// delimiters, and base64 expands three bytes to four characters.
encodedWordMaxBytes = (75 - len(encodedWordPrefix) - len(encodedWordSuffix)) / 4 * 3
// maxHeaderValueOctets is the longest value emitted unfolded. RFC 5322 caps
// a line at 998 octets; the rest of the budget covers the field name.
maxHeaderValueOctets = 900
)
// encodeHeaderValue prepares a rendered value for use as a header value. Line
// breaks become spaces so the value cannot terminate the header and inject
// another.
func encodeHeaderValue(value string) string {
if strings.ContainsAny(value, "\r\n") {
value = strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' {
return ' '
}
return r
}, value)
}
// A forged encoded-word is printable ASCII, which mime.WordEncoder passes
// through untouched for the recipient's client to decode.
if strings.Contains(value, "=?") {
return encodeWords(value)
}
// Length is measured on the encoded form: Q-encoding expands a non-ASCII
// rune to three characters per byte, so a short value can still exceed the
// line limit. WordEncoder separates words with a space rather than folding,
// so anything over the limit goes to encodeWords.
if encoded := mime.QEncoding.Encode("utf-8", value); len(encoded) <= maxHeaderValueOctets {
return encoded
}
return encodeWords(value)
}
// encodeWords emits value as RFC 2047 base64 encoded-words, joined with CRLF
// and a space so they both concatenate per RFC 2047 and fold per RFC 5322.
func encodeWords(value string) string {
var words []string
for len(value) > 0 {
n := encodedWordMaxBytes
if n >= len(value) {
n = len(value)
} else {
// Each encoded-word must decode on its own, so a multi-byte rune
// cannot straddle two of them.
for n > 0 && !utf8.RuneStart(value[n]) {
n--
}
if n == 0 {
// A rune wider than the budget: emit it whole rather than
// splitting it into something undecodable.
_, n = utf8.DecodeRuneInString(value)
}
}
words = append(words, encodedWordPrefix+
base64.StdEncoding.EncodeToString([]byte(value[:n]))+encodedWordSuffix)
value = value[n:]
}
return strings.Join(words, "\r\n ")
}
@@ -2,6 +2,7 @@ package dispatch
import (
"html"
"mime"
"strings"
"testing"
@@ -116,3 +117,102 @@ func TestValidateFromAddr(t *testing.T) {
})
}
}
func TestEncodeHeaderValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{
name: "ascii is unchanged",
value: `User account "bobby" suspended`,
want: `User account "bobby" suspended`,
},
{
name: "crlf is folded",
value: "Subject\r\nBcc: attacker@example.com",
want: "Subject Bcc: attacker@example.com",
},
{
name: "bare newline is folded",
value: "Subject\nBcc: attacker@example.com",
want: "Subject Bcc: attacker@example.com",
},
{
name: "non-ascii is q-encoded",
value: "Konto gelöscht",
want: "=?utf-8?q?Konto_gel=C3=B6scht?=",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := encodeHeaderValue(tc.value)
require.Equal(t, tc.want, got)
// The result must never be able to terminate its own header.
require.NotContains(t, got, "\r")
require.NotContains(t, got, "\n")
})
}
}
// TestEncodeHeaderValueEncodedWord covers a forged RFC 2047 encoded-word, which
// is printable ASCII and so passes mime.WordEncoder through to the client.
func TestEncodeHeaderValueEncodedWord(t *testing.T) {
t.Parallel()
// Decodes to "URGENT: verify your account".
const forged = "=?utf-8?B?VVJHRU5UOiB2ZXJpZnkgeW91ciBhY2NvdW50?="
got := encodeHeaderValue(forged + " shared a chat with you")
// The forged word must not survive as something a client would decode.
require.NotContains(t, got, forged)
// Decoded rather than compared: chunk boundaries are an implementation detail.
decoded, err := new(mime.WordDecoder).DecodeHeader(got)
require.NoError(t, err)
require.Equal(t, forged+" shared a chat with you", decoded)
}
// TestEncodeHeaderValueFolds covers RFC 5322's 998-octet line limit, which
// mime.WordEncoder does not fold for.
func TestEncodeHeaderValueFolds(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
"non-ascii": strings.Repeat("é", 600),
"ascii": strings.Repeat("a b ", 400),
// A rune that does not divide evenly into the per-word budget must not
// be split across two encoded-words: each has to decode on its own.
"multibyte": strings.Repeat("日本語", 400),
// Under the raw byte limit and over it once Q-encoded, so these fail
// unless the gate measures the encoded form.
"200 accented runes": strings.Repeat("é", 200),
"300 cjk runes": strings.Repeat("日", 300),
"200 emoji": strings.Repeat("🎉", 200),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
got := encodeHeaderValue(value)
for _, line := range strings.Split(got, "\r\n") {
require.LessOrEqual(t, len(line), 998,
"a header line exceeds RFC 5322's limit: %d octets", len(line))
}
// A CRLF must begin a continuation, or this is injection not folding.
for _, after := range strings.Split(got, "\r\n")[1:] {
require.True(t, strings.HasPrefix(after, " "),
"a CRLF was not followed by folding whitespace: %q", got)
}
decoded, err := new(mime.WordDecoder).DecodeHeader(got)
require.NoError(t, err)
require.Equal(t, value, decoded)
})
}
}
+128
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"log"
"strings"
"sync"
"testing"
@@ -632,3 +633,130 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) {
})
}
}
// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject
// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit.
func TestSMTPSubjectHeader(t *testing.T) {
t.Parallel()
const (
hello = "localhost"
to = "bob@bob.com"
body = "This is the body"
)
tests := []struct {
name string
// title is the rendered title template handed to the dispatcher.
title string
// wantSubject, when set, is the exact Subject header value.
wantSubject string
// wantSubjectContains are substrings the single Subject line must hold,
// used where pinning exact output would test glamour, not the header.
wantSubjectContains []string
// wantAbsent must not appear anywhere in the transmitted message.
wantAbsent string
}{
{
name: "plain subject",
title: "This is the subject",
wantSubject: "This is the subject",
},
{
name: "newline cannot inject a header",
// PlaintextFromMarkdown keeps the paragraph break, so this reaches
// the header writer with newlines in it.
title: "Innocent subject\n\nBcc: attacker@example.com",
wantSubjectContains: []string{"Innocent subject", "Bcc: attacker@example.com"},
wantAbsent: "\r\nBcc:",
},
{
name: "non-ascii subject is encoded",
title: "Konto gelöscht",
wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
cfg := codersdk.NotificationsEmailConfig{
Hello: serpent.String(hello),
From: serpent.String("system@coder.com"),
}
backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}})
srv, listen, err := smtptest.CreateMockSMTPServer(backend, false)
require.NoError(t, err)
t.Cleanup(func() {
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
})
var hp serpent.HostPort
require.NoError(t, hp.Set(listen.Addr().String()))
cfg.Smarthost = serpent.String(hp.String())
handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp"))
var wg sync.WaitGroup
wg.Go(func() {
assert.NoError(t, srv.Serve(listen))
})
require.Eventually(t, func() bool {
cl, err := smtptest.PingClient(listen, false, false)
if err != nil {
return false
}
_ = cl.Close()
return true
}, testutil.WaitShort, testutil.IntervalFast)
payload := types.MessagePayload{
Version: "1.0",
UserEmail: to,
Labels: make(map[string]string),
}
dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers())
require.NoError(t, err)
retryable, err := dispatchFn(ctx, uuid.New())
require.NoError(t, err)
require.False(t, retryable)
msg := backend.LastMessage()
require.NotNil(t, msg)
// Assertions are scoped to the header block, which a blank line ends.
headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n")
require.True(t, found, "message has no header/body separator")
// The header must occupy exactly one line, whatever the value held.
require.Equal(t, 1, strings.Count(headers, "Subject: "),
"exactly one Subject header must be present")
_, after, found := strings.Cut(headers, "Subject: ")
require.True(t, found, "no Subject header in %q", headers)
subject, _, found := strings.Cut(after, "\r\n")
require.True(t, found, "Subject header is not CRLF terminated")
if tc.wantSubject != "" {
require.Equal(t, tc.wantSubject, subject)
}
for _, want := range tc.wantSubjectContains {
require.Contains(t, subject, want)
}
if tc.wantAbsent != "" {
require.NotContains(t, headers, tc.wantAbsent,
"a value must not be able to inject an additional header")
}
require.NoError(t, srv.Shutdown(ctx))
wg.Wait()
})
}
}
@@ -45,6 +45,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/rbac"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/coderd/util/syncmap"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
@@ -2492,3 +2493,90 @@ func (n *acquireSignalingInterceptor) AcquireNotificationMessages(ctx context.Co
n.acquiredChan <- struct{}{}
return messages, err
}
// renderCapture records what the notifier renders, so a test can assert on what
// a dispatcher would receive.
type renderCapture struct {
mu sync.Mutex
title, body string
captured chan struct{}
once sync.Once
}
func newRenderCapture() *renderCapture {
return &renderCapture{captured: make(chan struct{})}
}
func (c *renderCapture) Dispatcher(_ types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) {
return func(_ context.Context, _ uuid.UUID) (bool, error) {
c.mu.Lock()
c.title, c.body = title, body
c.mu.Unlock()
c.once.Do(func() { close(c.captured) })
return false, nil
}, nil
}
func (c *renderCapture) wait(t *testing.T) (title, body string) {
t.Helper()
testutil.TryReceive(testutil.Context(t, testutil.WaitLong), t, c.captured)
c.mu.Lock()
defer c.mu.Unlock()
return c.title, c.body
}
// TestNotificationMarkdownInjection is the end-to-end regression test for
// https://linear.app/codercom/issue/SEC-93.
func TestNotificationMarkdownInjection(t *testing.T) {
t.Parallel()
const payload = "Eve\n## URGENT: SSO certificate expiring\n" +
"[Re-authenticate now](https://coder-sso.attacker.example/login)"
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, pubsub := dbtestutil.NewDB(t)
logger := testutil.Logger(t)
method := database.NotificationMethodSmtp
cfg := defaultNotificationsConfig(method)
capture := newRenderCapture()
mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager"))
require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
method: capture,
database.NotificationMethodInbox: &fakeHandler{},
})
t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx))
})
enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal())
require.NoError(t, err)
user := createSampleUser(t, store)
// WHEN: the notification interpolates an attacker-controlled display name
_, err = enq.Enqueue(ctx, user.ID, notifications.TemplateUserAccountSuspended, map[string]string{
"suspended_account_name": "eve",
"suspended_account_user_name": payload,
"initiator": "admin",
"account_type": "user",
}, "test")
require.NoError(t, err)
mgr.Run(ctx)
_, body := capture.wait(t)
// THEN: the rendered Markdown carries no structure from the display name.
html := markdown.HTMLFromNotificationMarkdown(body)
plain, err := markdown.PlaintextFromMarkdown(body)
require.NoError(t, err)
for _, tag := range []string{"<a ", "<img ", "<h1", "<h2", "<h3"} {
require.NotContains(t, html, tag, "rendered HTML: %s", html)
}
// The attacker's text still appears, as inert text.
require.Contains(t, html, "URGENT: SSO certificate expiring")
require.Contains(t, html, "Re-authenticate now")
require.NotContains(t, plain, `\`, "escapes must be consumed by the renderer")
}
+8 -2
View File
@@ -250,11 +250,17 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification
return nil, decorateHelpersError{err}
}
// Label and data values are user-controlled while the templates around them
// are not, so Markdown structure in a value is neutralized before it reaches
// the template. The dispatcher still receives the unescaped payload, because
// the webhook contract surfaces enqueued values verbatim.
escaped := payload.EscapedForMarkdown()
var title, body string
if title, err = render.GoTemplate(msg.TitleTemplate, payload, helpers); err != nil {
if title, err = render.GoTemplate(msg.TitleTemplate, escaped, 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, escaped, helpers); err != nil {
return nil, xerrors.Errorf("render body: %w", err)
}
@@ -38,5 +38,5 @@
"title": "There might be a problem with a recently claimed prebuilt workspace",
"title_markdown": "There might be a problem with a recently claimed prebuilt workspace",
"body": "Workspace my-workspace was claimed from a prebuilt workspace by prebuilds-claimer.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\ndocker_container[0] was replaced due to changes to env, hostname\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see this guide (https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the particle-accelerator preset.",
"body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container[0]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n"
"body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container\\[0\\]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n"
}
+58
View File
@@ -0,0 +1,58 @@
package types
import "github.com/coder/coder/v2/coderd/render"
// EscapedForMarkdown returns a copy of the payload whose string values have
// Markdown structure neutralized, for rendering the title and body templates.
// The receiver is left untouched: the stored payload keeps the values as they
// were enqueued, which is what the webhook dispatcher surfaces to consumers and
// what the SMTP dispatcher escapes at its own HTML sinks.
func (p MessagePayload) EscapedForMarkdown() MessagePayload {
out := p
out.UserName = render.EscapeMarkdown(p.UserName)
if p.Labels != nil {
labels := make(map[string]string, len(p.Labels))
for k, v := range p.Labels {
labels[k] = render.EscapeMarkdown(v)
}
out.Labels = labels
}
if p.Data != nil {
data := make(map[string]any, len(p.Data))
for k, v := range p.Data {
data[k] = escapeValue(v)
}
out.Data = data
}
return out
}
// escapeValue walks a decoded JSON value and escapes its string leaves. Numbers,
// booleans and nulls pass through unchanged so that template comparisons such as
// `{{if gt $version.failed_count 1}}` keep working.
//
// Nested keys are escaped too: a key is content whenever a template ranges with
// two variables, as the resource replacements body does over Terraform resource
// addresses.
func escapeValue(v any) any {
switch t := v.(type) {
case string:
return render.EscapeMarkdown(t)
case map[string]any:
out := make(map[string]any, len(t))
for k, vv := range t {
out[render.EscapeMarkdown(k)] = escapeValue(vv)
}
return out
case []any:
out := make([]any, len(t))
for i, vv := range t {
out[i] = escapeValue(vv)
}
return out
default:
return v
}
}
+132
View File
@@ -0,0 +1,132 @@
package types_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/notifications/types"
)
func TestEscapedForMarkdown(t *testing.T) {
t.Parallel()
t.Run("EscapesLabelsAndUserName", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
UserName: "Eve [Re-auth](https://attacker.example)",
Labels: map[string]string{
"suspended_account_user_name": "Eve\n## URGENT\n[Re-auth](https://attacker.example)",
"limit_source": "user_override",
},
}
escaped := payload.EscapedForMarkdown()
require.Equal(t, `Eve \[Re-auth\]\(https://attacker.example\)`, escaped.UserName)
require.NotContains(t, escaped.Labels["suspended_account_user_name"], "[Re-auth](")
// Control values must survive so template conditionals keep matching.
require.Equal(t, "user_override", escaped.Labels["limit_source"])
})
t.Run("LeavesReceiverUntouched", func(t *testing.T) {
t.Parallel()
// The webhook dispatcher surfaces the payload verbatim, so the original
// must not mutate.
payload := types.MessagePayload{
UserName: "Eve [x](https://attacker.example)",
Labels: map[string]string{"name": "bobby-workspace", "risky": "[x](https://attacker.example)"},
Data: map[string]any{"user": map[string]any{"name": "[x](https://attacker.example)"}},
}
_ = payload.EscapedForMarkdown()
require.Equal(t, "Eve [x](https://attacker.example)", payload.UserName)
require.Equal(t, "[x](https://attacker.example)", payload.Labels["risky"])
require.Equal(t, "[x](https://attacker.example)",
payload.Data["user"].(map[string]any)["name"])
})
t.Run("RecursesIntoData", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
Data: map[string]any{
"user": map[string]any{"name": "[x](https://attacker.example)"},
"archived_chats": []any{
map[string]any{"title": "[x](https://attacker.example)"},
},
},
}
escaped := payload.EscapedForMarkdown()
require.Equal(t, `\[x\]\(https://attacker.example\)`,
escaped.Data["user"].(map[string]any)["name"])
require.Equal(t, `\[x\]\(https://attacker.example\)`,
escaped.Data["archived_chats"].([]any)[0].(map[string]any)["title"])
})
t.Run("PreservesNonStringLeaves", func(t *testing.T) {
t.Parallel()
// Body templates compare numbers, as {{if gt $version.failed_count 1}}
// does, so coercing them to strings would break the comparison.
payload := types.MessagePayload{
Data: map[string]any{
"failed_count": 3.0,
"enabled": true,
"absent": nil,
"versions": []any{map[string]any{"failed_count": 1.0}},
},
}
escaped := payload.EscapedForMarkdown()
require.Equal(t, 3.0, escaped.Data["failed_count"])
require.Equal(t, true, escaped.Data["enabled"])
require.Nil(t, escaped.Data["absent"])
require.Equal(t, 1.0, escaped.Data["versions"].([]any)[0].(map[string]any)["failed_count"])
})
t.Run("NilMapsStayNil", func(t *testing.T) {
t.Parallel()
escaped := types.MessagePayload{}.EscapedForMarkdown()
require.Nil(t, escaped.Labels)
require.Nil(t, escaped.Data)
})
t.Run("EscapesNestedMapKeys", func(t *testing.T) {
t.Parallel()
// A nested key is content when a template ranges with two variables.
payload := types.MessagePayload{
Data: map[string]any{
"replacements": map[string]any{
"[Re-auth](https://attacker.example/login)": "paths",
"null_resource.ok": "other",
},
},
}
escaped := payload.EscapedForMarkdown()
replacements, ok := escaped.Data["replacements"].(map[string]any)
require.True(t, ok)
require.Contains(t, replacements, `\[Re-auth\]\(https://attacker.example/login\)`)
require.NotContains(t, replacements, "[Re-auth](https://attacker.example/login)")
// A key with nothing to escape must stay resolvable by name.
require.Contains(t, replacements, "null_resource.ok")
})
t.Run("LeavesTopLevelDataKeysAlone", func(t *testing.T) {
t.Parallel()
// Top-level .Data keys are dereferenced by name, not content, so escaping
// one breaks the lookup.
payload := types.MessagePayload{
Data: map[string]any{"failed_builds": []any{"x"}},
}
require.Contains(t, payload.EscapedForMarkdown().Data, "failed_builds")
})
}
+188
View File
@@ -0,0 +1,188 @@
package render
import "strings"
// Character classes for EscapeMarkdown, split by where each character carries
// structural meaning. The split is what keeps escaping away from the enum-like
// label values that body templates compare with `eq`, such as "user_override",
// "bobby-workspace" and "1.5": escaping one changes template control flow.
const (
// inlineCritical characters can produce a link, an image, an angle autolink,
// or forge an escape from anywhere in a value, so they are always escaped.
//
// Backtick is here, not in blockStart, because a fence's info string is an
// HTML sink: gomarkdown writes it into class="language-..." unescaped, and
// SkipHTML does not apply to a CodeBlock node. Escaping only the leading
// backtick would leave two, which open an inline code span.
inlineCritical = "\\[]()!<`"
// blockStart characters carry structural meaning only as the first
// non-space character of a line, so they are escaped only there.
blockStart = `#-+.>|`
// leadingEmphasis characters carry inline meaning anywhere but also open a
// block construct in leading position: "* " starts a bullet list, and three
// or more of either character starts a thematic break. Escaping them only
// there costs emphasis that begins on a line boundary.
leadingEmphasis = `*_`
// foldStart characters also carry meaning only at the start of a line, but
// glamour does not honor a backslash before them, so escaping would leave a
// literal backslash in the plaintext part. The preceding line break becomes
// a space instead, denying them the line-start position.
//
// ":" is here for the GFM delimiter row ":-- | --:" as well as definition
// lists. Escaping "|" does not reach that row: its pipes are mid-line.
foldStart = `=~:`
// maxLeadingSpaces is the widest indentation a line may keep, since four
// spaces open an indented code block and a space cannot be escaped.
maxLeadingSpaces = 3
)
// EscapeMarkdown neutralizes Markdown structure in an untrusted value so that it
// renders as literal text through both HTMLFromNotificationMarkdown and
// PlaintextFromMarkdown. Line breaks are preserved so multi-line values keep
// their shape. Other control characters are dropped, being the carrier for SMTP
// header injection.
//
// Known residual: a template that wraps the value in a code span. CommonMark
// does not process escapes inside one, so the backslashes emitted here reach
// the reader. Nothing here can detect that, since the sink is decided after
// this runs.
func EscapeMarkdown(s string) string {
if s == "" {
return s
}
lines := strings.Split(stripControl(s), "\n")
var b strings.Builder
b.Grow(len(s) + len(s)/8)
for i, line := range lines {
if i > 0 {
// Joining a fold-start line to the previous one takes it out of
// leading position.
if opensFoldConstruct(line) {
_ = b.WriteByte(' ')
} else {
_ = b.WriteByte('\n')
}
}
// The first line has no preceding break to fold, so escaping is the only
// lever left there.
_, _ = b.WriteString(escapeLine(line, i == 0 && isLeadingFoldConstruct(line)))
}
return b.String()
}
// stripControl keeps line breaks, turns the other whitespace controls into
// spaces and drops the rest. Carriage returns are folded rather than kept so a
// value cannot terminate an SMTP header.
func stripControl(s string) string {
if strings.IndexFunc(s, isStrippable) < 0 {
return s
}
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r == '\n':
_, _ = b.WriteRune(r)
case r == '\r' || r == '\t' || r == '\v' || r == '\f':
_, _ = b.WriteRune(' ')
case r < 0x20 || r == 0x7f:
// Dropped.
default:
_, _ = b.WriteRune(r)
}
}
return b.String()
}
func isStrippable(r rune) bool {
return r != '\n' && (r < 0x20 || r == 0x7f)
}
// escapeLine escapes one line's structural characters and truncates its
// indentation to maxLeadingSpaces.
//
// escapeFold additionally escapes a leading "=" or "~". Only EscapeMarkdown's
// first line passes it, and only when that line really is a fold construct.
func escapeLine(line string, escapeFold bool) string {
var b strings.Builder
b.Grow(len(line))
leading := true
spaces := 0
// digitRun reports whether the line so far is nothing but indentation and
// digits, which is the only position where "." opens an ordered list.
digitRun := false
for i, r := range line {
switch {
case r < 0x80 && strings.ContainsRune(inlineCritical, r):
_ = b.WriteByte('\\')
_, _ = b.WriteRune(r)
case leading && r == ' ':
// Indentation keeps the next character in leading position.
if spaces < maxLeadingSpaces {
_, _ = b.WriteRune(r)
spaces++
}
continue
case leading && r < 0x80 && strings.ContainsRune(blockStart+leadingEmphasis, r):
_ = b.WriteByte('\\')
_, _ = b.WriteRune(r)
case leading && escapeFold && (r == '=' || r == '~'):
_ = b.WriteByte('\\')
_, _ = b.WriteRune(r)
case digitRun && r == '.' && closesMarker(line, i):
// The "1." of an ordered list. Its sibling "1)" needs no case
// because ")" is inlineCritical and is always escaped.
_ = b.WriteByte('\\')
_, _ = b.WriteRune(r)
default:
_, _ = b.WriteRune(r)
}
digitRun = (leading || digitRun) && r >= '0' && r <= '9'
leading = false
}
return b.String()
}
// closesMarker reports whether the single-byte list-marker delimiter at i is
// followed by a space or ends the line, as CommonMark requires of a marker.
// That requirement is what keeps a value such as "1.5" out of the escaped set.
// Tabs need no handling: stripControl has already folded them into spaces.
func closesMarker(line string, i int) bool {
return i+1 == len(line) || line[i+1] == ' '
}
// opensFoldConstruct reports whether a line's first non-space character is a
// foldStart character. Approximate on purpose: it only decides whether to drop
// a line break, which costs nothing.
func opensFoldConstruct(line string) bool {
t := strings.TrimLeft(line, " ")
if t == "" {
return false
}
return strings.ContainsRune(foldStart, rune(t[0]))
}
// isLeadingFoldConstruct reports whether a line is itself a tilde fence opener
// or a Setext "=" underline, rather than merely starting with one of those
// characters. Exact, because it governs escaping and the backslash is visible:
// "=> next" must not acquire one, while "~~~" must, since an unterminated fence
// at the start of a title renders the Subject, <title> and heading empty.
//
// Indentation is ignored: escapeLine truncates it to maxLeadingSpaces, which
// still leaves the line able to open a block. ":" is excluded because a
// definition list or table needs a preceding line that a first line lacks.
func isLeadingFoldConstruct(line string) bool {
t := strings.TrimLeft(line, " ")
if strings.HasPrefix(t, "~~~") {
return true
}
t = strings.TrimRight(t, " ")
return t != "" && strings.Trim(t, "=") == ""
}
+313
View File
@@ -0,0 +1,313 @@
package render
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// asciiPunctuation is every ASCII punctuation character, the set CommonMark
// declares escapable.
const asciiPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
// TestEscapableSet pins the escapes each renderer honors, which is what
// EscapeMarkdown's character classes are derived from.
func TestEscapableSet(t *testing.T) {
t.Parallel()
// Measured against gomarkdown and glamour as vendored today.
const (
wantHTML = "!#$&()*+-.:<>[\\]^_`{|}~"
wantPlain = "!#()*+-.<>[\\]_`{|}"
)
var gotHTML, gotPlain strings.Builder
for _, r := range asciiPunctuation {
escaped := `X\` + string(r) + `Y`
// HTML escapes markup characters, so compare the entity form.
wantLiteral := "X" + string(r) + "Y"
switch r {
case '<':
wantLiteral = "X&lt;Y"
case '>':
wantLiteral = "X&gt;Y"
case '&':
wantLiteral = "X&amp;Y"
case '"':
wantLiteral = "X&quot;Y"
}
html := strings.TrimSuffix(strings.TrimPrefix(HTMLFromMarkdown(escaped), "<p>"), "</p>")
if html == wantLiteral {
_, _ = gotHTML.WriteRune(r)
}
plain, err := PlaintextFromMarkdown(escaped)
require.NoError(t, err)
if plain == "X"+string(r)+"Y" {
_, _ = gotPlain.WriteRune(r)
}
}
require.Equal(t, wantHTML, gotHTML.String(),
"the set of characters gomarkdown honors as escapes has changed; re-derive EscapeMarkdown's character classes")
require.Equal(t, wantPlain, gotPlain.String(),
"the set of characters glamour honors as escapes has changed; re-derive EscapeMarkdown's character classes")
// Every character EscapeMarkdown escapes must be honored by both renderers.
for _, r := range inlineCritical + blockStart + leadingEmphasis {
assert.Contains(t, wantHTML, string(r), "gomarkdown does not honor \\%s", string(r))
assert.Contains(t, wantPlain, string(r), "glamour does not honor \\%s", string(r))
}
// foldStart characters are folded precisely because they are not escapable.
for _, r := range foldStart {
assert.NotContains(t, wantPlain, string(r),
"glamour now honors \\%s, so it could be escaped instead of folded", string(r))
}
}
// TestEscapeMarkdownControlValues guards the label values body templates compare
// with `eq`. Escaping one silently changes template control flow.
func TestEscapeMarkdownControlValues(t *testing.T) {
t.Parallel()
for _, v := range []string{
"user_override", // migrations/000553, {{if eq .Labels.limit_source "user_override"}}
"service", // migrations/000568, {{if eq .Labels.account_type "service"}}
"0", // migrations/000480, {{if eq .Data.retention_days "0"}}
"autobuild",
"initiator",
"user-override",
"1.5",
"10.0.0.1",
"bobby-workspace",
} {
require.Equal(t, v, EscapeMarkdown(v), "escaping changed a control value")
}
}
func TestEscapeMarkdown(t *testing.T) {
t.Parallel()
// Emphasis and code tags are accepted residuals: no destination.
structuralTags := []string{
"<a ", "<img ", "<h1", "<h2", "<h3", "<h4", "<h5", "<h6",
"<ul", "<ol", "<hr", "<blockquote", "<table",
}
t.Run("NeutralisesStructure", func(t *testing.T) {
t.Parallel()
type structureCase struct {
name string
value string
// inertRaw marks a value neutralized by something other than marker
// escaping. Leaving it unset on one fails the liveness check below.
inertRaw bool
}
for _, tc := range []structureCase{
{name: "DisclosurePayload", value: "Eve\n## URGENT: SSO certificate expiring\n[Re-authenticate now](https://coder-sso.attacker.example/login)"},
{name: "InlineLink", value: "[Re-authenticate now](https://attacker.example/login)"},
// A link reference definition is not recognized mid-paragraph.
{name: "ReferenceLink", value: "[Re-auth][1]\n\n[1]: https://attacker.example", inertRaw: true},
{name: "Image", value: "![px](https://tracker.attacker.example/p.gif)"},
{name: "AngleAutolink", value: "Eve <https://attacker.example>"},
// Neutralized by autolinking being off. See
// TestEscapeMarkdownNoAutolink.
{name: "BareURL", value: "Eve https://attacker.example/login", inertRaw: true},
{name: "Mailto", value: "Eve mailto:eve@attacker.example", inertRaw: true},
{name: "ATXHeading", value: "Eve\n## URGENT"},
{name: "SetextH1", value: "URGENT: re-auth required\n===\nx"},
{name: "SetextH1Spaced", value: "Eve\n=== \n#### x"},
{name: "SetextH1Repeated", value: "Eve\n===\n===\nx"},
{name: "SetextH2", value: "Eve\n---\nx"},
{name: "ThematicBreak", value: "Eve\n----\nx"},
{name: "ThematicBreakStars", value: "Eve\n***\nnext"},
{name: "ThematicBreakUnderscores", value: "Eve\n___\nnext"},
{name: "ThematicBreakSpacedStars", value: "Eve\n* * *\nnext"},
{name: "ThematicBreakSpacedUnderscores", value: "Eve\n_ _ _\nnext"},
{name: "BulletList", value: "Eve\n- one\n- two"},
{name: "BulletListStar", value: "Eve\n* one\n* two"},
{name: "BulletListStarIndented", value: "Eve\n * one\n * two"},
{name: "OrderedList", value: "Eve\n1. one\n2. two"},
{name: "OrderedListMultiDigit", value: "Eve\n99. one\n100. two"},
{name: "OrderedListParen", value: "Eve\n1) one\n2) two"},
{name: "Blockquote", value: "Eve\n> quoted"},
// Neutralized by the Tables extension being off; the escaper's own
// handling is covered by TestEscapeMarkdownColon.
{name: "Table", value: "a | b\n--- | ---\nc | d", inertRaw: true},
// Neutralized by the safelink policy. See
// TestEscapeMarkdownNoAutolink/SafelinkRejectsUnsafeSchemes.
{name: "JavascriptScheme", value: "[click](javascript:alert(1))", inertRaw: true},
// Asserts escaping does not re-enable the value's own backslashes.
{name: "EscapeForging", value: `Eve \[Re-auth\](https://attacker.example)`, inertRaw: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Rendered twice, the second with line breaks doubled: only a
// blank line lets a block construct interrupt a paragraph.
rawProducedTag := false
for _, value := range []string{tc.value, strings.ReplaceAll(tc.value, "\n", "\n\n")} {
escaped := EscapeMarkdown(value)
html := HTMLFromNotificationMarkdown(suspendedBody(escaped))
plain, err := PlaintextFromMarkdown(suspendedBody(escaped))
require.NoError(t, err)
raw := HTMLFromNotificationMarkdown(suspendedBody(value))
for _, tag := range structuralTags {
assert.NotContains(t, html, tag, "value %q rendered HTML: %s", value, html)
rawProducedTag = rawProducedTag || strings.Contains(raw, tag)
}
// A backslash the value did not contain means a character the
// renderer does not honor was escaped.
if !strings.Contains(value, `\`) {
assert.NotContains(t, html, `\`, "value %q leaked a literal backslash into HTML", value)
assert.NotContains(t, plain, `\`, "value %q leaked a literal backslash into plaintext", value)
}
}
// Without this, a row whose value can never reach a line-start
// position passes whether or not EscapeMarkdown runs.
if !tc.inertRaw {
assert.True(t, rawProducedTag,
"vacuous row: %q produces no structural tag even unescaped, so the assertions above guard nothing; fix the value or set inertRaw with a reason",
tc.value)
}
})
}
})
t.Run("PreservesBenignValues", func(t *testing.T) {
t.Parallel()
// Also why this change leaves the notification golden files untouched.
for _, value := range []string{
"William Tables",
"bobby-workspace",
"Bobby's Template",
"O'Brien-Smith (Eng) 100%",
"autodeleted due to dormancy (autobuild)",
"José Müller 日本語",
// Documented multi-line custom notification, see
// docs/admin/monitoring/notifications/index.md.
"Test results:\n • ✅ success",
"Test results:\n • ❌ failed (3 tests failed)",
} {
t.Run(value, func(t *testing.T) {
t.Parallel()
escaped := EscapeMarkdown(value)
require.Equal(t, HTMLFromNotificationMarkdown(suspendedBody(value)), HTMLFromNotificationMarkdown(suspendedBody(escaped)))
wantPlain, err := PlaintextFromMarkdown(suspendedBody(value))
require.NoError(t, err)
gotPlain, err := PlaintextFromMarkdown(suspendedBody(escaped))
require.NoError(t, err)
require.Equal(t, wantPlain, gotPlain)
})
}
})
t.Run("ControlCharacters", func(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
value string
want string
}{
{"KeepsNewlines", "a\nb", "a\nb"},
{"FoldsCarriageReturn", "a\r\nb", "a \nb"},
{"FoldsTab", "a\tb", "a b"},
{"FoldsVerticalTab", "a\vb", "a b"},
{"DropsNul", "a\x00b", "ab"},
{"DropsBell", "a\x07b", "ab"},
{"DropsDelete", "a\x7fb", "ab"},
{"Empty", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, EscapeMarkdown(tc.value))
})
}
})
t.Run("AngleBracketsAreNeutralised", func(t *testing.T) {
t.Parallel()
// "Ops <ops@example.com>" used to render as a mailto anchor; turning it
// into text is a deliberate behavior change.
html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown("Ops <ops@example.com>")))
require.NotContains(t, html, "<a ")
require.Contains(t, html, "&lt;ops@example.com&gt;")
})
t.Run("EmphasisIsNotEscaped", func(t *testing.T) {
t.Parallel()
// Mid-line "*" and "_" carry no destination and escaping "_" corrupts
// control values. Backtick reaches the info-string sink, so it is escaped.
require.Equal(t, "Eve *_\\`~", EscapeMarkdown("Eve *_`~"))
// Leading "*" and "_" open a list or thematic break. "~" is denied the
// line-start position by the fold, glamour not honoring "\~".
require.Equal(t, "\\*_\\`~", EscapeMarkdown("*_`~"))
require.Equal(t, "\\_*\\`~", EscapeMarkdown("_*`~"))
})
}
// TestEscapeMarkdownNoAutolink: a URL in an untrusted value must not become an
// anchor, while links in the trusted template markdown keep working.
func TestEscapeMarkdownNoAutolink(t *testing.T) {
t.Parallel()
t.Run("UntrustedValueProducesNoAnchor", func(t *testing.T) {
t.Parallel()
for _, value := range []string{
"Eve https://attacker.example/login",
"Eve [Re-auth](https://attacker.example/login)",
"Eve <https://attacker.example>",
"Eve mailto:eve@attacker.example",
"Eve http://attacker.example",
} {
html := HTMLFromNotificationMarkdown("Account **" + EscapeMarkdown(value) + "** suspended.")
assert.NotContains(t, html, "<a ", "value %q produced an anchor", value)
}
})
t.Run("TrustedTemplateLinksStillRender", func(t *testing.T) {
t.Parallel()
// Shapes taken from shipped notification body templates.
for _, markdown := range []string{
"marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of x",
"See [the docs](https://coder.com/docs/admin/templates/troubleshooting).",
} {
html := HTMLFromNotificationMarkdown(markdown)
assert.Contains(t, html, `<a href="https://coder.com/docs/`, "trusted link did not render: %s", html)
}
})
t.Run("HTMLFromMarkdownStillAutolinks", func(t *testing.T) {
t.Parallel()
// The shared renderer keeps Autolink, so the OIDC signups-disabled page
// still linkifies. Safelink does now apply; see TestHTMLFromMarkdownSafelink.
require.Contains(t, HTMLFromMarkdown("see https://coder.com/docs"), `<a href="https://coder.com/docs"`)
})
t.Run("SafelinkRejectsUnsafeSchemes", func(t *testing.T) {
t.Parallel()
for _, dest := range []string{"javascript:alert(1)", "data:text/html;base64,PHNjcmlwdD4="} {
html := HTMLFromNotificationMarkdown(fmt.Sprintf("[click](%s)", dest))
assert.NotContains(t, html, "<a ", "unsafe scheme %q was linked", dest)
}
})
}
+321
View File
@@ -0,0 +1,321 @@
package render
import (
"strings"
"testing"
"github.com/gomarkdown/markdown/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
xhtml "golang.org/x/net/html"
)
// permissive is the grammar notificationExtensions used to enable. Tests that
// must exercise the escaper rather than the allowlist render against it.
const permissive = parser.CommonExtensions | parser.HardLineBreak
// suspendedBody mirrors the live TemplateUserAccountSuspended body: the
// untrusted value sits mid-paragraph with trusted text on both sides.
func suspendedBody(value string) string {
return "The account belongs to **" + value + "** and it was suspended by **rob**."
}
// TestEscapeMarkdownFenceInfo covers the info-string sink that made backtick
// inlineCritical: a `"` closes the class attribute and a `>` closes the tag.
func TestEscapeMarkdownFenceInfo(t *testing.T) {
t.Parallel()
const info = `"><a/href="https://attacker.example/login">Click here`
// Each payload needs a line after the closing fence, or the template's
// trailing text lands on it and the fence stops being one.
for name, value := range map[string]string{
"Anchor": "Eve\n\n```" + info + "\nhidden\n```\nmore",
"Image": "Eve\n\n```\"><img/src=x/onerror=alert(1)>\nhidden\n```\nmore",
"Tilde": "Eve\n\n~~~" + info + "\nhidden\n~~~\nmore",
"AtStart": "```" + info + "\nhidden\n```\nmore",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value)))
assert.NotContains(t, html, `class="language-`,
"an info string reached the class attribute: %s", html)
assert.NotContains(t, html, "<a/", "the info string produced an anchor: %s", html)
assert.NotContains(t, html, "<img/", "the info string produced an image: %s", html)
})
}
// Liveness: unescaped, the anchor payload must reach the sink.
raw := HTMLFromNotificationMarkdown(suspendedBody("Eve\n\n```" + info + "\nhidden\n```\nmore"))
require.Contains(t, raw, `class="language-`,
"vacuous test: the payload no longer reaches the info-string sink even unescaped")
}
// TestEscapeMarkdownColon covers ":", rendered under CommonExtensions so the
// shipped allowlist, which kills these constructs anyway, cannot carry the test.
func TestEscapeMarkdownColon(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
"DefinitionList": "Term\n: definition",
"TableColonBoth": "a | b\n:-- | --:\nc | d",
"TableColonCentre": "a | b\n:-: | :-:\nc | d",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
escaped := EscapeMarkdown(value)
html := renderHTML(suspendedBody(escaped), permissive)
plain, err := PlaintextFromMarkdown(suspendedBody(escaped))
require.NoError(t, err)
for _, tag := range []string{"<table", "<dl", "<dt", "<dd"} {
assert.NotContains(t, html, tag, "value %q rendered %s", value, html)
}
assert.NotContains(t, plain, `\`, "folding ':' should not leak a backslash: %q", plain)
// Liveness: unescaped, each value must produce one of those tags.
raw := renderHTML(suspendedBody(value), permissive)
assert.True(t,
strings.Contains(raw, "<table") || strings.Contains(raw, "<dl"),
"vacuous row: %q produces no table or definition list even unescaped", value)
})
}
}
// TestNotificationExtensionsDropUnusedGrammar pins the allowlist: each construct
// is openable from an untrusted value and used by no template.
func TestNotificationExtensionsDropUnusedGrammar(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct{ markdown, tag string }{
"Tables": {"a | b\n:-- | --:\nc | d", "<table"},
"DefinitionLists": {"Term\n: definition", "<dl"},
"MathJax": {"Eve $x^2$ end", `class="math`},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
require.Contains(t, renderHTML(tc.markdown, permissive), tc.tag,
"the construct is no longer reachable under CommonExtensions, so this test guards nothing")
assert.NotContains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.tag,
"notificationExtensions still enables %s", name)
})
}
// What shipped templates do use must keep rendering.
for _, tc := range []struct{ markdown, want string }{
{"see [the docs](https://coder.com/docs/x).", `<a href="https://coder.com/docs/x"`},
{"Your workspace **foo** was suspended.", "<strong>foo</strong>"},
{"Resources:\n\n- one\n- two\n", "<li>"},
{"marked as [**dormant**](https://coder.com/docs/y) because", `<a href="https://coder.com/docs/y"`},
} {
assert.Contains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.want)
}
}
// TestEscapeMarkdownIndentedCode covers the one construct with no escape: a
// space cannot be escaped, so the indent run is truncated instead.
func TestEscapeMarkdownIndentedCode(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
"FourSpaces": "Eve\n\n hidden",
"EightSpaces": "Eve\n\n hidden",
"SingleBreak": "Eve\n hidden",
"DeepInList": "Eve\n\n - hidden",
"OnlyIndented": " hidden",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value)))
assert.NotContains(t, html, "<pre", "value %q produced a code block: %s", value, html)
})
}
// Indentation up to the cap is preserved, so documented multi-line custom
// notification values keep their shape.
require.Equal(t, "Test results:\n • ok", EscapeMarkdown("Test results:\n • ok"))
require.Equal(t, "a\n b", EscapeMarkdown("a\n b"))
require.Equal(t, "a\n b", EscapeMarkdown("a\n b"))
}
// TestEscapeMarkdownEmptyLinkDestination covers the panic html.Safelink
// introduced: parser.IsSafeURL slices a destination before bounds-checking it.
func TestEscapeMarkdownEmptyLinkDestination(t *testing.T) {
t.Parallel()
for _, md := range []string{
"[our docs]()", "![px]()", "[a]( )", "[](https://coder.com)", "[a](x)",
} {
assert.NotPanics(t, func() { _ = HTMLFromNotificationMarkdown(md) }, "markdown %q", md)
// Reachable outside notifications, via OIDCConfig.SignupsDisabledText.
assert.NotPanics(t, func() { _ = HTMLFromMarkdown(md) }, "markdown %q", md)
}
// Destinations Safelink still permits must keep rendering.
for _, tc := range []struct{ md, want string }{
{"[a](https://coder.com)", `<a href="https://coder.com"`},
{"[a](/path)", `<a href="/path"`},
{"[a](./p)", `<a href="./p"`},
{"[a](mailto:x@y.z)", `<a href="mailto:x@y.z"`},
} {
assert.Contains(t, HTMLFromNotificationMarkdown(tc.md), tc.want)
}
// And the ones it rejects must stay rejected.
for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} {
assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ", "unsafe scheme linked: %s", md)
}
// Safelink also drops these two, a silent loss for a template author rather
// than a security property. Pinned so renderHTML's comment cannot drift.
for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} {
assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ",
"destination %q now renders an anchor; update the comment on renderHTML", md)
}
}
// TestEscapeMarkdownLeadingFoldConstruct covers a first line the fold cannot
// reach. Escaping costs a visible backslash, so the untouched cases matter too.
func TestEscapeMarkdownLeadingFoldConstruct(t *testing.T) {
t.Parallel()
t.Run("TitleKeepsItsTrustedText", func(t *testing.T) {
t.Parallel()
for _, value := range []string{"~~~", "~~~~", "~~~x", "~~~ ", " ~~~"} {
subject, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " shared a chat with you")
require.NoError(t, err)
assert.Contains(t, subject, "shared a chat with you",
"value %q swallowed the trusted subject text", value)
}
// The backtick spelling is closed by backtick being inlineCritical.
subject, err := PlaintextFromMarkdown(EscapeMarkdown("```") + " shared a chat with you")
require.NoError(t, err)
require.Equal(t, "``` shared a chat with you", subject)
})
t.Run("SetextCannotPromoteATrustedLine", func(t *testing.T) {
t.Parallel()
for _, value := range []string{"===", "=", "===\nx", " === "} {
html := HTMLFromNotificationMarkdown(
"Trusted line\n" + EscapeMarkdown(value) + "\nTrusted trailer.")
assert.NotContains(t, html, "<h1", "value %q promoted a heading: %s", value, html)
}
})
t.Run("NonConstructsAreUntouched", func(t *testing.T) {
t.Parallel()
// These begin with a fold character without being a construct; escaping
// them would put a backslash in front of an ordinary display name.
for _, value := range []string{
"=> next", "~tilde name", "= x", "~~strike~~", "=?utf-8?q?x?=",
"~", "~~", "=== and more", "a\n===",
} {
plain, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " end")
require.NoError(t, err)
assert.NotContains(t, plain, `\`,
"value %q was escaped when it is not a fold construct", value)
}
})
}
// TestRecoverToEscapedSource drives renderHTML's panic guard directly: safeURL
// closed the only input known to panic it.
func TestRecoverToEscapedSource(t *testing.T) {
t.Parallel()
const src = `<script>alert(1)</script> & "quoted"`
got := recoverToEscapedSource(src, func() string { panic("boom") })
assert.Equal(t, xhtml.EscapeString(src), got)
// The point of escaping rather than returning the source: no markup escapes.
assert.NotContains(t, got, "<script>")
assert.Equal(t, "rendered",
recoverToEscapedSource(src, func() string { return "rendered" }))
}
// TestHTMLFromMarkdownSafelink pins the behavior change Safelink brought to the
// shared renderer, called by OIDCConfig.SignupsDisabledText.
func TestHTMLFromMarkdownSafelink(t *testing.T) {
t.Parallel()
// Unsafe schemes stopped linking here, not just in notifications.
for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} {
assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "unsafe scheme linked: %s", md)
}
// Fragment and bare relative destinations stopped linking too, a silent loss
// rather than a security property. See renderHTML.
for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} {
assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "destination %q now links", md)
}
// What the signups-disabled text actually uses must keep working.
for _, tc := range []struct{ md, want string }{
{"see https://coder.com/docs", `<a href="https://coder.com/docs"`},
{"[docs](https://coder.com/docs)", `<a href="https://coder.com/docs"`},
{"contact [us](mailto:support@coder.com)", `<a href="mailto:support@coder.com"`},
{"**bold** and _italic_", "<strong>bold</strong>"},
} {
assert.Contains(t, HTMLFromMarkdown(tc.md), tc.want)
}
}
// TestEscapeMarkdownResiduals pins the one gap EscapeMarkdown cannot close.
func TestEscapeMarkdownResiduals(t *testing.T) {
t.Parallel()
t.Run("CodeSpanSwallowsEscapes", func(t *testing.T) {
t.Parallel()
// CommonMark does not process escapes inside a code span, and the
// workspace out-of-disk body wraps a value in one.
html := HTMLFromNotificationMarkdown("The volume `" + EscapeMarkdown("config[0]") + "` is full.")
require.Contains(t, html, `config\[0\]`,
"if this no longer leaks, the residual is closed and this test should become an assertion that it stays closed")
})
}
// TestEscapeMarkdownNoStrayBackslash asserts the no-stray-backslash invariant
// across every interpolation position a shipped template provides.
func TestEscapeMarkdownNoStrayBackslash(t *testing.T) {
t.Parallel()
positions := map[string]func(string) string{
"midline": suspendedBody,
"linestart": func(v string) string { return v + " shared a chat with you." },
"afterblank": func(v string) string { return "Hi.\n\n" + v + "\n\nRegards." },
"listitem": func(v string) string { return "Resources:\n\n- " + v + "\n" },
"trailing": func(v string) string { return "The account belongs to **" + v + "**" },
}
for _, value := range []string{
"William Tables", "bobby-workspace", "Bobby's Template",
"O'Brien-Smith (Eng) 100%", "José Müller 日本語",
"config[0]", "vol(1)", "1.5", "user_override",
} {
for name, pos := range positions {
t.Run(name+"/"+value, func(t *testing.T) {
t.Parallel()
md := pos(EscapeMarkdown(value))
html := HTMLFromNotificationMarkdown(md)
plain, err := PlaintextFromMarkdown(md)
require.NoError(t, err)
assert.NotContains(t, html, `\`, "stray backslash in HTML: %s", html)
assert.NotContains(t, plain, `\`, "stray backslash in plaintext: %q", plain)
assert.False(t, strings.Contains(html, `class="language-`),
"benign value reached the info-string sink: %s", html)
})
}
}
}
+76 -2
View File
@@ -113,12 +113,86 @@ func PlaintextFromMarkdown(markdown string) (string, error) {
return strings.TrimSpace(output), nil
}
// notificationExtensions is an allowlist. Shipped templates use only core
// CommonMark, so Tables, DefinitionLists, MathJax and Autolink are absent; each
// is openable from an untrusted label value. Adding one back means revisiting
// EscapeMarkdown.
const notificationExtensions = parser.NoIntraEmphasis | parser.HardLineBreak
func HTMLFromMarkdown(markdown string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak.
return renderHTML(markdown, parser.CommonExtensions|parser.HardLineBreak) // Added HardLineBreak.
}
// HTMLFromNotificationMarkdown converts a rendered notification body to HTML.
// Unlike HTMLFromMarkdown it does not autolink bare URLs, because notification
// bodies interpolate attacker-controlled label values.
func HTMLFromNotificationMarkdown(markdown string) string {
return renderHTML(markdown, notificationExtensions)
}
// longestURLPath is the longest relative-path prefix parser.IsSafeURL compares
// against. Derived so a dependency bump that adds a longer one stays correct.
var longestURLPath = func() int {
longest := 0
for _, p := range parser.Paths {
if len(p) > longest {
longest = len(p)
}
}
return longest
}()
// safeURL wraps parser.IsSafeURL, which slices a destination to each candidate
// prefix length before checking it is that long, and so panics on a short one
// with no spare capacity, as "[docs]()" produces. Padding the capacity keeps
// the slice in bounds; IsSafeURL's own guards still decide the result.
func safeURL(url []byte) bool {
if cap(url) < longestURLPath {
padded := make([]byte, len(url), longestURLPath)
copy(padded, url)
url = padded
}
return parser.IsSafeURL(url)
}
// recoverToEscapedSource runs render and, if it panics, returns the source
// HTML-escaped instead: the notification still arrives, showing Markdown
// source, and no markup escapes. Kept separate from renderHTML so the recovery
// is testable, safeURL having closed the only input known to panic.
func recoverToEscapedSource(markdown string, render func() string) (out string) {
defer func() {
if r := recover(); r != nil {
out = xhtml.EscapeString(markdown)
}
}()
return render()
}
// renderHTML converts Markdown to HTML. Input is untrusted, so a parser panic
// is recovered rather than taking down the dispatcher.
//
// Safelink silently drops fragment and bare relative destinations, so [a](#x)
// and [a](docs/x.md) render without an anchor. /path, ./path, mailto: and
// http(s):// still work.
func renderHTML(markdown string, extensions parser.Extensions) string {
return recoverToEscapedSource(markdown, func() string {
return renderHTMLUnsafe(markdown, extensions)
})
}
// renderHTMLUnsafe is renderHTML without the panic guard.
func renderHTMLUnsafe(markdown string, extensions parser.Extensions) string {
p := parser.NewWithExtensions(extensions)
p.IsSafeURLOverride = safeURL
doc := p.Parse([]byte(markdown))
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.SkipHTML,
// Safelink restricts generated hrefs to trusted schemes, which keeps
// javascript: and data: out of rendered output.
Flags: html.CommonFlags | html.SkipHTML | html.Safelink,
})
// Safelink routes every destination through parser.IsSafeURL, which panics
// on a short one. The hook lives on the renderer, not on its options.
renderer.IsSafeURLOverride = safeURL
return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer)))
}
+5
View File
@@ -26,6 +26,11 @@ exclude_pathspecs=(
# Generated CLI golden files embed serpent's emdash-bordered footer.
":(exclude)cli/testdata/*.golden"
":(exclude)enterprise/cli/testdata/*.golden"
# Generated notification golden files embed every stored notification
# template, and one carries an emdash from before this check existed
# (migration 000324). It lives in an applied migration, so it cannot be
# edited in place.
":(exclude)coderd/notifications/testdata/rendered-templates/**/*.golden"
)
scan_all_files() {