mirror of
https://github.com/coder/coder.git
synced 2026-11-03 05:55:24 +08:00
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:
@@ -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, "=") == ""
|
||||
}
|
||||
@@ -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<Y"
|
||||
case '>':
|
||||
wantLiteral = "X>Y"
|
||||
case '&':
|
||||
wantLiteral = "X&Y"
|
||||
case '"':
|
||||
wantLiteral = "X"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: ""},
|
||||
{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, "<ops@example.com>")
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user