fix(coderd/notifications): HTML-escape the email template values - #28397
Conversation
Notification title and body templates are Markdown authored by Coder, but
the values interpolated into them are user-controlled and were substituted
through text/template, which does no escaping. A display name that any
member can set via PUT /users/{user}/profile, or that arrives unvalidated
from an OIDC or GitHub name claim on self-signup, could inject Markdown
structure into notifications delivered to every site Owner and User Admin.
A name such as "Eve\n## URGENT\n[Re-authenticate now](https://evil)"
rendered a live anchor and heading in the email body. The same string also
renders as a clickable link in the dashboard notification popover, which
displays inbox content as Markdown. html.SkipHTML does not help here: the
anchor is generated from Markdown syntax, not present as raw HTML in the
input.
Neutralize Markdown structure in label, data, and UserName values before
they reach the template. This happens in notifier.prepare so that
already-queued messages are covered and the stored payload keeps its
original values for webhook consumers. The character classes are narrow on
purpose:
- "\[]()!<" are escaped everywhere, as they can carry a destination.
- "#-+.>|" are escaped only in leading position, so mid-token occurrences
in values like "bobby-workspace" are untouched.
- "=~" are not escapable in either renderer, so the preceding line break
is folded instead. That denies them the line-start position a Setext
heading or tilde fence requires.
- Emphasis characters are left alone. They cannot carry a destination, and
escaping "_" corrupts values such as "user_override" that body templates
compare with eq, which silently dropped a paragraph from the AI budget
notifications.
Also drop Autolink for notification bodies, so neither a bare URL in a
value nor the URL inside escaped link syntax becomes an anchor; enable
html.Safelink to restrict generated hrefs to safe schemes; and fold line
breaks out of the Subject header while Q-encoding it, so a rendered value
cannot terminate the header and inject another.
EscapeMarkdown treated block-level meaning as "the line's first character is in blockStart", which missed two constructs. An ordered list opens with a digit run followed by ".", and the digit cleared the leading flag before the "." was reached. A bullet list and a thematic break open with "*" or "_", which were left unescaped as emphasis. A display name of "Eve\n\n1. malicious" therefore rendered <ol> into a notification body, and "***", "___", "* * *" or "_ _ _" rendered <hr>. Those are the same structural tags the package already asserts against. Escape "*" and "_" in leading position, and escape the "." that closes an ordered-list marker when a leading digit run precedes it and a space or the line end follows it. That following-space condition is CommonMark's rule for a list marker, and it keeps "1.5" and "10.0.0.1" out of the escaped set: body templates compare numeric label values with `eq`. Both renderers honor these escapes, so the rendered text and the notification golden files are unchanged. NeutralisesStructure now renders every value twice, once with its line breaks doubled. The existing OrderedList row passed only because a list cannot interrupt a paragraph without a blank line, so it exercised a shape that could never fire.
NeutralisesStructure rows are regression guards only if their value produces document structure without escaping. "Eve\n1. one" did not: a list cannot interrupt a paragraph without a blank line, so the digit-dot never reached a line-start position and the row passed whether or not EscapeMarkdown ran. Render each value a second time with escaping removed and require a structural tag, so a row that guards nothing fails at authoring time. Five values are inert by construction and carry inertRaw with the reason: two rely on the renderer having autolinking disabled, one on the safelink policy, one on a link reference definition not being recognized mid-paragraph, and one on its own backslashes. Reverting the doubled-line-break shape now fails six list rows instead of passing silently.
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 5 findings (2 P2, 1 P3, 2 Nit), COMMENT. Review Finding inventoryFindings
Round logRound 1Panel. Netero + 16 reviewers. 2 P2, 1 P3, 2 Nit new. Reviewed against 6a9c44c..486df7a. Law analysisNot run (effective additions 174 <= 1000). About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Targeted fix for a real XSS class: the two sinks Markdown escaping cannot reach (.Labels._subject after PlaintextFromMarkdown entity-decodes, and .UserName passed raw by the dispatcher) are now closed at the template. The new test refuses to lie by accident: require.NotEqual(tc.injected, escaped, "case carries no HTML to escape, so it guards nothing") blocks a future maintainer from adding a benign case that would pass whether or not the sink escapes anything. The doc comment on TestSMTPHTMLTemplateEscapesSubjectAndUserName and the commit body both name the sink, the mechanism (& is not backslash-escapable, PlaintextFromMarkdown decodes entities), and where the fix has to land, in six lines each. The 36 golden files are entity-only (mostly "->" / '->') with one behavior-adjacent &->& in the OTP action URL, called out explicitly.
Bisky's opener sums it up: "Oh, this is charming work. Real payloads run through the real dispatcher steps, the assertions catch the raw markup, and there's a self-check that fails the case if the injected string has nothing to escape."
Five findings: 2 P2, 1 P3, 2 Nit.
- P2 (CRF-1) The test named for the new sinks covers
.Labels._subjectand.UserNameonly.payload.Actionsis left as its nil zero value, so{{ range $action := .Actions }}never iterates. Removing| htmlfrom$action.Labelproduces zero golden diffs; the two action sinks the PR patches have no dedicated regression test. - P2 (CRF-3)
html.gotmplis parsed bytext/template, so escaping stays opt-in at every interpolation. Two sinks in this same template already violate the pattern the PR is establishing (see CRF-2). Structural alternative: parse the HTML template withhtml/template, type_bodyastemplate.HTML(it is trusted gomarkdown output), and drop the manual| htmlpipes. This needs a human decision: adopt in a follow-up or explicitly accept the recurring escape-at-every-sink obligation. - P3 (CRF-2)
base_urlreaches threehrefand text sinks in the same template without| html. Verified in a scratch probe thatnet/urlpreserves"in the query, so a--access-urlwith a raw"breaks out of the anchor attribute; even for benign multi-parameter URLs, the output is non-conformant HTML (raw&in an attribute). Same threat model aslogo_url, which the parent PR #28340 already defended. - Nit (CRF-4, CRF-5)
appearanceHelpers()duplicates the inline map at:120and its name mis-scopes (returns all four helpers, not appearance-specific).
Process notes:
- The PR description quantifies verification (36/36 golden files re-checked by quoted-printable round-trip) instead of asserting confidence. That is the shape every notification-security PR should ship with.
- Scope is proportional: five one-token template edits, one focused test file, mechanical golden churn. No drive-by refactors.
- CI:
Pixel / Reviewis failing but has nothing to do with SMTP escaping; treat as unrelated unless someone can trace it to this diff.
Drafted by Coder Agents (automated review, https://coder.com/docs/ai-coder/agents-review); confirm with the reviewers listed.
coderd/notifications/dispatch/smtp/html.gotmpl:28
P3 [CRF-2] base_url is interpolated raw into three href and one visible-text sink in the same template that this PR is hardening; logo_url and app_name on the same lines are already | html-escaped. (Razor P3, Meruem P3)
Line 28:
<a href="{{ base_url }}" ...>{{ base_url }}</a>(attribute and text)
Line 29:<a href="{{ base_url }}/settings/notifications" ...>
Line 30:<a href="{{ base_url }}/settings/notifications?disabled={{ .NotificationTemplateID }}" ...>
Verified in a scratch probe against Go 1.24's net/url in this worktree that " in the query survives url.Parse -> String() round-trip unchanged:
in : https://coder.example.com/?q=a"onclick=alert(1)
out: https://coder.example.com/?q=a"onclick=alert(1)
An operator setting --access-url to a URL with a raw " (or any HTML-active character) lands that character inside href="..." in every SMTP notification. cli/server.go:448 only validates the scheme (http/https), not the content. Even for benign URLs, & in an href attribute must be &; the current output is non-conformant HTML for any AccessURL with a multi-parameter query.
base_url is options.AccessURL.String() from server startup config, so the attacker in the threat model is a deployment operator (or someone who can set that flag) rather than an admin API user. That is a narrower surface than logo_url and app_name (mutable admin API), but the previous PR still chose to escape both because the escape is trivial and closes the class. Fix: {{ base_url | html }} at all three call sites, or fold into CRF-3.
🤖
🤖 This review was automatically generated with Coder Agents.
The rendered-template goldens embed the body of every stored notification template, and one of those bodies carries an emdash predating this check (migration 000324, "workspace startup...even when claiming a prebuilt environment"). The text lives in an applied migration, so it cannot be edited in place, and any change that regenerates the goldens fails the check on a character it did not introduce. Same rationale as the existing cli/testdata exclusions: generated files whose content comes from somewhere the author does not control.
Five gaps found by re-probing the escaper against the real render pipeline.
Backtick moves to inlineCritical. A fenced block's info string is an HTML
sink: gomarkdown writes it into class="language-..." unescaped, and
html.SkipHTML does not apply because the node is a CodeBlock rather than an
HTMLBlock. A display name that closes the attribute and the tag put a live
anchor in mail to every user admin, reachable by any member via
PUT /users/me/profile.
Colon joins foldStart. It opens a definition list, and also a GFM table
delimiter row such as ":-- | --:" that escaping "|" cannot reach, because a
delimiter row's pipes are mid-line. Folded rather than escaped because
glamour does not honor "\:" and would leak a backslash into the plaintext
part.
Leading indentation is capped at three spaces. Four open an indented code
block and a space has no escape, so the run is truncated instead.
escapeValue now escapes nested map keys. It walked a decoded JSON value and
escaped its string leaves, copying keys verbatim, but a key is content
whenever a template ranges over the map with two variables:
{{range $resource, $paths := .Data.replacements}}
Those keys are Terraform resource addresses from provisioner output, so an
unescaped one rendered a live anchor to every template admin.
renderHTML guards Safelink. parser.IsSafeURL slices a destination to each
candidate prefix length before checking the destination is that long, so
"[docs]()" panicked. That reached HTMLFromMarkdown as well, making it live
outside notifications via OIDCConfig.SignupsDisabledText. A length-safe
override plus a recover, mirroring InnerTextFromMarkdown.
encodeHeaderValue handles two cases mime.WordEncoder cannot. It only encodes
a value holding a byte outside printable ASCII, and an RFC 2047 encoded-word
is nothing but printable ASCII, so a forged one reached the recipient's mail
client and was decoded there. It also joins words with a space rather than
folding, leaving long headers past RFC 5322's 998-octet limit.
notificationExtensions becomes an allowlist of the grammar templates use
rather than CommonExtensions minus Autolink. That drops Tables,
DefinitionLists and MathJax, each reachable from an untrusted value and used
by no shipped template, with no change to any template's rendering.
Three residuals stay open and are pinned by tests that fail if they close:
escapes are inert inside a template-supplied code span, a value's own first
line cannot be folded, and a title beginning "~~~" still renders an empty
subject. All three depend on where the value lands rather than what it
contains, which is what a pre-render escaper cannot see. They close under
placeholder substitution, not under another character class.
One golden moves, contradicting the current PR description: the resource
replacements body_markdown now reads docker_container\[0\].
…r.prepare The comment claimed the dispatcher "escapes at its own sinks". It does not. smtp/html.gotmpl renders through text/template, and its _subject, UserName, _body and action URL interpolations have no escaping, while logo_url and app_name do. _subject has also been through PlaintextFromMarkdown by then, which strips the escaping applied here back out. State the real reason the dispatcher gets the unescaped payload, which is that the webhook contract surfaces enqueued values verbatim, and name the sinks that are still bare so nobody reads this as a guarantee.
The fold that handles "=" and "~" can only remove a line break EscapeMarkdown itself emitted, so it never reaches the value's first line, where the template decides the position. Two consequences: A title template beginning with a label puts the value at the start of the document. A display name of "~~~" made the whole title an unterminated tilde fence whose info string was the trusted text, and glamour renders an empty code block, so the Subject, <title> and heading all came out blank. Reachable by any member via PUT /users/me/profile. A body template placing a value at a line start beneath a text line let "===" underline the trusted line into an <h1>. No shipped template does this, but nothing recorded the dependency either. Escape the character instead of folding it. Neither renderer honors "\=" or "\~", so the backslash reaches the reader, which is why isLeadingFoldConstruct is exact where opensFoldConstruct is approximate: folding a line that was not going to open anything is free, escaping one is not. "=> next" and a display name of "~tilde" keep rendering clean; "~~~" does not. A visible "\~~~" beats a Subject line that renders empty. ":" stays out of it. It is in foldStart for the definition list and table cases, both of which need a preceding line that a first line does not have.
Around a hundred comment lines across the escaper, the renderer and the SMTP header work restated what the code already says or re-explained CommonMark. Cut those, along with the placeholder-substitution asides that belong in the design doc rather than repeated across five function comments. Kept what a reader cannot derive from the code: that gomarkdown writes a fence info string into class="language-..." unescaped and SkipHTML does not apply to a CodeBlock node; that glamour honors neither "\:" nor "\=" nor "\~", which is why those fold instead of escaping; that parser.IsSafeURL slices before bounds-checking; that mime.WordEncoder passes printable ASCII through untouched; and why opensFoldConstruct may be approximate where isLeadingFoldConstruct has to be exact. Test comments keep their notes on how a case could go vacuous, since that is what stops someone deleting a row that looks redundant.
Enabling Safelink also stopped fragment and bare relative destinations from linking, so [a](#x) and [a](docs/x.md) now render without an anchor while /path, ./path, mailto: and http(s):// keep working. No shipped template is affected, but a future template author gets silent link loss with no error. Pinned by two rows in TestEscapeMarkdownEmptyLinkDestination so the comment cannot drift away from the behavior.
486df7a to
8d1c3db
Compare
The gate measured the raw value, but Q-encoding expands a non-ASCII rune to three characters per byte, so 200 accented characters (400 bytes) cleared the 900-byte gate and still emitted a single 1459-octet header line, past RFC 5322's 998. Measure the encoded form instead. [CRF-4] Also from the same review: - The shared renderer is not unchanged, as a test comment claimed. It routes through renderHTML, so Safelink applies to HTMLFromMarkdown as well, and OIDCConfig.SignupsDisabledText silently stopped linking unsafe schemes, fragments and bare relative destinations. Correct the comment and pin both the changed behavior and what that caller actually needs. [CRF-5] - Split renderHTML's panic guard into recoverToEscapedSource so it has a test. Driving it through renderHTML would not have reached it: safeURL closed the only input known to panic, so such a test would pass without exercising the recovery at all. [CRF-6] - Drop a duplicated body closure in favor of suspendedBody, and hoist a twice-declared permissive const to package level. [CRF-7, CRF-8]
The subject is produced by PlaintextFromMarkdown, which strips Markdown and decodes HTML entities, and html.gotmpl then interpolated it raw into <title> and <h1>. An entity-encoded payload in a label therefore arrived as live markup: a template_display_name of "<a href=...>Re-authenticate now</a>" rendered an anchor in the email. UserName reached the same template raw from the unescaped payload the dispatcher is handed. Markdown escaping cannot close this. "&" is not backslash-escapable in either renderer, and the subject never enters gomarkdown, so SkipHTML and Safelink do not see it. The fix belongs at the template sink. Add "| html" to .Labels._subject at both sites, .UserName, $action.URL and $action.Label. .Labels._body stays raw: it is gomarkdown output. The 36 golden files change by entity encoding only. Verified by quoted-printable decoding both sides and confirming they are identical once entities are decoded, which also covers the one action URL whose "&" became "&".
…scape The comment on notifier.prepare listed _subject, UserName, _body and the action URL as bare, which was true before this change and is not after it. Four of those five now escape with `| html`; only _body is still interpolated raw, which is correct, since it is already-rendered HTML.
8d1c3db to
209321d
Compare
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.
#28340 landed as a squash, so main already carries its files, including the review changes made after this branch was cut. Every conflict is resolved to main, with this PR's own changes re-applied on top: - notifier.go: main's trimmed comment, extended to record that html.gotmpl escapes at its own sinks. - smtp_internal_test.go: main's version plus this PR's escaping tests. - smtp.go, smtp_test.go, notifications_test.go, types/escape*.go, render/escape*.go, render/markdown.go and check_emdash.sh taken from main verbatim; this branch changed none of them. Golden files regenerated with `make coderd/notifications/.gen-golden`, which confirmed the auto-merged output was already correct.
Test names and require messages carry this already. Dropped the godoc block, the struct field comments and the case comment, and kept only the two facts a reader cannot recover from the code: that appearanceHelpers is benign by design, and that PlaintextFromMarkdown is what turns the encoded title into live markup.
The test was named for the values the fix escapes but exercised only _subject and UserName. Actions was left nil, so the range block never iterated and neither action value reached the template. Dropping | html from $action.Label changed no golden file and failed no test; dropping it from $action.URL moved one character in one golden, which a regenerate loop absorbs. Adds a case per action value and renames the test to match what it covers. Each of the four | html pipes now fails a named subtest when removed. Also folds the duplicated helper map into templateHelpers, renamed from appearanceHelpers: it returns all four helpers, and only logo_url and app_name are appearance settings. Not named helpers(), which already exists in dispatch_test with different values.
base_url reached three href attributes and one visible-text position raw, while logo_url and app_name beside them were already escaped. net/url preserves a quote in a query and --access-url is validated for its scheme only, so an operator can land a quote inside href="...", where it closes the attribute and the rest becomes live markup. A raw & in an attribute is also invalid HTML for any multi-parameter URL. current_year and .NotificationTemplateID are escaped in the same pass. Neither can carry markup in production, but leaving them raw meant the rule for this file was "escaped, except two", which is the ambiguity that let base_url sit unescaped through the previous round. Escaping these costs no golden churn, so nothing already in the tree fails if it is removed again. TestSMTPHTMLTemplateEscapesTrustedValues injects a quote into each of the three. Every value the template interpolates now fails a named test when its escaping is dropped, with _body the one deliberate exception: it is trusted rendered Markdown.
|
Thanks, this was a useful round. Four of the five are addressed in CRF-1 (P2), action values untested. Accepted, fixed in Confirmed your measurement before fixing it: dropping One correction to the framing, because it changes how the finding should be
CRF-2 (P3), Reproduced both links in the chain: Escaped Escaping all of these costs zero golden churn, which is exactly why they needed Net result across both commits: all nine distinct values this template CRF-3 (P2), Agreed on the substance, and your framing is right that it needs a human Recording the main obstacle for whoever picks it up, since it is more than a The payoff is what you describe: context-correct escaping by construction, CRF-4 (Nit), duplicated helper map. Accepted, fixed in Collapsed the older test onto the shared helper. One small correction: it is two CRF-5 (Nit), You are right that the name is wrong: only Renamed to Two process notes. The review praises a six-line doc comment on On |
snagles
left a comment
There was a problem hiding this comment.
Since this is stacked and working to get a CVE fix out fast rather than going for the larger refactor I think this makes sense. I share the other reviewers concerns on this kind of being kind of a footgun. If we're going to follow up quickly on this I'm good with it as is

Follows #28340, now merged.
smtp.gorenders the notification title throughPlaintextFromMarkdown, which strips Markdown and decodes HTML entities, then stores the result inLabels["_subject"].html.gotmplinterpolated that raw into<title>and<h1>, so an entity-encoded payload in a user-controlled label arrived as live markup:Markdown escaping cannot reach this.
&is not backslash-escapable in either renderer, and this path never enters gomarkdown, so neitherhtml.SkipHTMLnor theSafelinkadded in #28340 sees the string.{{ .UserName }}was interpolated raw at the same template, straight from the unescaped payload the dispatcher receives.This PR adds
| htmlto seven values across eleven positions inhtml.gotmpl:.Labels._subject<title>and<h1>.UserName$action.URLenqueuer.go:201;EscapedForMarkdowndoes not touchActions$action.Labelbase_url--access-urlis scheme-checked only, so a"closes thehrefcurrent_year.NotificationTemplateIDlogo_urlandapp_namewere already escaped in #28340.{{ .Labels._body }}stays unescaped: it is intentionally gomarkdown output, and it is the only value in the file that is not escaped.The action and
base_urlvalues are defense in depth rather than open holes. A"in an action URL fails closed at enqueue, because the rendered actions JSON is unmarshalled before use and the quote breaks that parse;<,>,&and'survive but are inert inside a double-quoted attribute.base_urlrequires an operator to set a hostile--access-url.Every value is guarded by a test. Removing
| htmlfrom any of the nine escaped values now fails a named test, verified by removing each pipe in turn:TestSMTPHTMLTemplateEscapesUntrustedValuescovers_subject,UserNameand both action values.TestSMTPHTMLTemplateEscapesTrustedValuescoversbase_url,current_yearand.NotificationTemplateID, none of which can carry markup in production, so no golden file would catch their regression.TestSMTPHTMLTemplateEscapesAppearanceHelperscoverslogo_urlandapp_name.That last point is why the trusted values needed tests rather than goldens: escaping them costs zero golden churn, so nothing already in the tree fails when it is removed. Before this PR the same was true of
$action.Label, whose escaping could be deleted with no golden diff and no failing test at all.The 36 golden files change by entity encoding only, mostly
"to"and'to'in subjects. Verified by quoted-printable decoding every file before and after and confirming the two are identical once HTML entities are decoded: 36/36 with no semantic difference. Escapingbase_url,current_yearand.NotificationTemplateIDadded no further churn.NOTE:
$action.URL | htmlturns the&in the one-time passcode reset link into&. That is the correct encoding of a literal&in an attribute value, and every conformant client decodes it before navigating, so the request the server receives is unchanged. It is the only golden change with behavior attached.Migrating this template to
html/templatewas considered and declined; the reasoning and the conditions that would reverse it are on the CRF-3 review thread.Refs https://linear.app/codercom/issue/PLAT-273/markdown-link-injection-into-admin-notification-emails-sec-93