mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
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.
322 lines
12 KiB
Go
322 lines
12 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|
|
}
|