fix(coderd/notifications): HTML-escape the email template values by BobbyHo · Pull Request #28397 · coder/coder · GitHub
Skip to content

fix(coderd/notifications): HTML-escape the email template values - #28397

Merged
BobbyHo merged 22 commits into
mainfrom
coder-plat-273-sec-93-html-sinks
Aug 25, 2026
Merged

fix(coderd/notifications): HTML-escape the email template values#28397
BobbyHo merged 22 commits into
mainfrom
coder-plat-273-sec-93-html-sinks

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Follows #28340, now merged.

smtp.go renders the notification title through PlaintextFromMarkdown, which strips Markdown and decodes HTML entities, then stores the result in Labels["_subject"]. html.gotmpl interpolated that raw into <title> and <h1>, so an entity-encoded payload in a user-controlled label arrived as live markup:

template_display_name = &lt;a href="https://attacker.example/login"&gt;Re-authenticate now&lt;/a&gt;

  -> <title>Template "<a href="https://attacker.example/login">Re-authenticate now</a>" deleted</title>

Markdown escaping cannot reach this. & is not backslash-escapable in either renderer, and this path never enters gomarkdown, so neither html.SkipHTML nor the Safelink added in #28340 sees the string. {{ .UserName }} was interpolated raw at the same template, straight from the unescaped payload the dispatcher receives.

This PR adds | html to seven values across eleven positions in html.gotmpl:

Value Positions Why
.Labels._subject 2 the injection above, in <title> and <h1>
.UserName 1 reaches the template straight from the unescaped payload
$action.URL 1 rendered from user data at enqueuer.go:201; EscapedForMarkdown does not touch Actions
$action.Label 1 static today, escaped so it stays safe if that changes
base_url 4 --access-url is scheme-checked only, so a " closes the href
current_year 1 cannot carry markup, escaped so the rule has no exceptions
.NotificationTemplateID 1 same

logo_url and app_name were 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_url values 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_url requires an operator to set a hostile --access-url.

Every value is guarded by a test. Removing | html from any of the nine escaped values now fails a named test, verified by removing each pipe in turn:

  • TestSMTPHTMLTemplateEscapesUntrustedValues covers _subject, UserName and both action values.
  • TestSMTPHTMLTemplateEscapesTrustedValues covers base_url, current_year and .NotificationTemplateID, none of which can carry markup in production, so no golden file would catch their regression.
  • TestSMTPHTMLTemplateEscapesAppearanceHelpers covers logo_url and app_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 &#34; and ' to &#39; 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. Escaping base_url, current_year and .NotificationTemplateID added no further churn.

NOTE: $action.URL | html turns the & in the one-time passcode reset link into &amp;. 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/template was 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

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.
@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

SEC-93

PLAT-273

@BobbyHo

BobbyHo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-20 16:12 UTC by @BobbyHo

Review history
  • R1 (2026-08-20): 16 reviewers, 2 Nit, 2 P2, 1 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 6a9c44c..486df7a

Last posted: Round 1, 5 findings (2 P2, 1 P3, 2 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open smtp_internal_test.go:34 Test named for the new sinks covers only 2 of 4; $action.Label regression trips zero goldens R1 Chopper P2, Meruem P3, Netero P3, Bisky P4 Yes
CRF-2 P3 Open html.gotmpl:28 base_url remains unescaped at three href/text sinks with the same threat model as the already-escaped logo_url R1 Razor P3, Meruem P3 Yes
CRF-3 P2 Open html.gotmpl:6 Template runs on text/template, so escaping stays opt-in at every sink; two sinks in this file already violate the pattern R1 Meruem P2, Pariston Note, Razor Note Yes
CRF-4 Nit Open smtp_internal_test.go:18 appearanceHelpers() duplicates the inline helper map at :120 of the same file R1 Robin, Zoro, Chopper, Meruem, Bisky, Pariston Yes
CRF-5 Nit Open smtp_internal_test.go:18 appearanceHelpers name mis-scopes: it returns the full helper set (base_url, current_year, logo_url, app_name), not appearance helpers R1 Zoro Yes

Round log

Round 1

Panel. Netero + 16 reviewers. 2 P2, 1 P3, 2 Nit new. Reviewed against 6a9c44c..486df7a.

Law analysis

Not run (effective additions 174 <= 1000).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 "->&#34; / '->&#39;) with one behavior-adjacent &->&amp; 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._subject and .UserName only. payload.Actions is left as its nil zero value, so {{ range $action := .Actions }} never iterates. Removing | html from $action.Label produces zero golden diffs; the two action sinks the PR patches have no dedicated regression test.
  • P2 (CRF-3) html.gotmpl is parsed by text/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 with html/template, type _body as template.HTML (it is trusted gomarkdown output), and drop the manual | html pipes. This needs a human decision: adopt in a follow-up or explicitly accept the recurring escape-at-every-sink obligation.
  • P3 (CRF-2) base_url reaches three href and text sinks in the same template without | html. Verified in a scratch probe that net/url preserves " in the query, so a --access-url with 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 as logo_url, which the parent PR #28340 already defended.
  • Nit (CRF-4, CRF-5) appearanceHelpers() duplicates the inline map at :120 and 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 / Review is 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 &amp;; 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.

Comment thread coderd/notifications/dispatch/smtp_internal_test.go Outdated
Comment thread coderd/notifications/dispatch/smtp/html.gotmpl
Comment thread coderd/notifications/dispatch/smtp_internal_test.go Outdated
Comment thread coderd/notifications/dispatch/smtp_internal_test.go
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.
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
"&lt;a href=...&gt;Re-authenticate now&lt;/a&gt;" 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 "&amp;".
…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.
@BobbyHo
BobbyHo force-pushed the coder-plat-273-sec-93-html-sinks branch from 8d1c3db to 209321d Compare August 24, 2026 22:26
BobbyHo added a commit that referenced this pull request Aug 25, 2026
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.
Base automatically changed from coder-plat-273-sec-93 to main August 25, 2026 15:12
#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.

BobbyHo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, this was a useful round. Four of the five are addressed in
f5b526dbe8 and 029e14fd82; CRF-3 is left open deliberately.

CRF-1 (P2), action values untested. Accepted, fixed in f5b526dbe8.

Confirmed your measurement before fixing it: dropping | html from
$action.Label changes no golden file and fails no test, and dropping it from
$action.URL moves one character in one golden, which a regenerate loop
absorbs. Added RawHTMLInActionLabel and RawHTMLInActionURL, and renamed the
test to TestSMTPHTMLTemplateEscapesUntrustedValues so the name matches what it
covers.

One correction to the framing, because it changes how the finding should be
read. These two are defense in depth today, not a reachable injection. Action
URLs are templates rendered against the payload at enqueuer.go:201 and several
do interpolate user data ({{.UserName}}, {{.Labels.workspace}}), and
EscapedForMarkdown does not touch Actions, so I expected this to be live.
But the rendered actions JSON is unmarshalled before use, and a " breaks that
parse:

injected into workspace name result
" fails closed: parse template actions: invalid character
<, >, &, ' survives into $action.URL

" is the character needed to escape href="...", and it cannot get through.
What does survive is inert inside a double-quoted attribute. So the value here
is regression protection plus HTML conformance, which is how you rated it, and
the test comment records the enqueue behaviour so the next reader does not
mistake the case for a reachable payload.

CRF-2 (P3), base_url unescaped. Accepted, fixed in 029e14fd82.

Reproduced both links in the chain: net/url preserves a " in the query
through a Parse then String() round trip, and rendering that through the
unescaped interpolation closes the attribute and leaves onclick=alert(1) live
on the anchor. --access-url is only scheme-checked at cli/server.go:449, so
an operator can land it.

Escaped current_year and .NotificationTemplateID in the same commit. Neither
can carry markup in production, but leaving them raw meant the rule for this
file was "escaped, except two", and that ambiguity is what let base_url sit
unescaped through the previous round.

Escaping all of these costs zero golden churn, which is exactly why they needed
tests rather than goldens: nothing already in the tree fails when the escaping
is removed. TestSMTPHTMLTemplateEscapesTrustedValues injects a " into each
of the three.

Net result across both commits: all nine distinct values this template
interpolates, across thirteen interpolation points, now fail a named test when
their escaping is dropped. {{ .Labels._body }} is the single deliberate
exception, being trusted rendered Markdown.

CRF-3 (P2), text/template keeps escaping opt-in. Acknowledged, not in this
PR. Leaving this thread open.

Agreed on the substance, and your framing is right that it needs a human
decision rather than a drive-by change. Not taking it here: it is a
framework-level change to the render path, and this PR is a targeted fix.

Recording the main obstacle for whoever picks it up, since it is more than a
parser swap. _body is trusted gomarkdown output that must not be escaped, and
it is currently carried in payload.Labels["_body"] at smtp.go:80. Labels
is map[string]string, so the map type alone forbids holding a
template.HTML; this needs a dedicated field on MessagePayload or a separate
typed map. Also worth budgeting for: GoTemplate sets
Option("missingkey=invalid") and the comment at gotmpl.go:22 already notes
html/template differs here, and the golden churn will be real since
html/template escapes context-sensitively rather than uniformly.

The payoff is what you describe: context-correct escaping by construction,
javascript: and data: neutralized in href (which | html does not do),
and every | html pipe deleted. Will file a follow-up rather than close this
silently.

CRF-4 (Nit), duplicated helper map. Accepted, fixed in f5b526dbe8.

Collapsed the older test onto the shared helper. One small correction: it is two
identical values, not three. base_url and current_year match; logo_url and
app_name are both overridden with attack payloads, which your own suggested
fix reflects.

CRF-5 (Nit), appearanceHelpers mis-scopes. Critique accepted, suggested name
declined.

You are right that the name is wrong: only logo_url and app_name are
appearance settings (from fetcher.go:27-28), while base_url and
current_year come from startup config via templateHelpers(options).

Renamed to templateHelpers, not helpers. smtp_internal_test.go is the only
file in package dispatch; utils_test.go and the rest are dispatch_test.
So helpers() does compile, but it would put two identically named helpers in
one directory returning different values (http://test.com and 2024 versus
coder.example.com and 2026), which reads worse than the name being wrong.

Two process notes.

The review praises a six-line doc comment on
TestSMTPHTMLTemplateEscapesSubjectAndUserName. That comment was removed before
this round, so the praise no longer matches the tree; flagging it only so a human
reader is not confused by the mismatch.

On Pixel / Review: we have not independently traced it to this diff, so
treating it as unrelated is still unverified rather than established.

@BobbyHo
BobbyHo marked this pull request as ready for review August 25, 2026 19:24
@BobbyHo BobbyHo changed the title fix(coderd/notifications): HTML-escape the email template sinks fix(coderd/notifications): HTML-escape the email template values Aug 25, 2026
@snagles
snagles self-requested a review August 25, 2026 20:46

@snagles snagles left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@BobbyHo

BobbyHo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@BobbyHo
BobbyHo merged commit 2236710 into main Aug 25, 2026
32 checks passed
@BobbyHo
BobbyHo deleted the coder-plat-273-sec-93-html-sinks branch August 25, 2026 21:02
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 25, 2026
@BobbyHo BobbyHo added backport backport/v2.35 Backport PR targeting release/2.35 backport/v2.34 Backport PR targeting release/2.34 labels Aug 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

backport/v2.34 Backport PR targeting release/2.34 backport/v2.35 Backport PR targeting release/2.35 backport

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants