fix: prevent markdown injection in notifications by BobbyHo · Pull Request #28340 · coder/coder · GitHub
Skip to content

fix: prevent markdown injection in notifications - #28340

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

fix: prevent markdown injection in notifications#28340
BobbyHo merged 15 commits into
mainfrom
coder-plat-273-sec-93

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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.

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

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

@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-24 21:15 UTC by @BobbyHo

Review history
  • R1 (2026-08-20), 1 Nit, 1 P1, COMMENT. Review
  • R2 (2026-08-24), 3 Nit, 1 Note, 2 P1, 1 P2, 1 P3, COMMENT. Review

deep-review v0.9.0 | Round 2 | c029869..a671d84

Last posted: Round 2, 8 findings (2 P1, 1 P2, 1 P3, 3 Nit, 1 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #28340

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P1 Author fixed (7844bce) coderd/render/escape.go:17 EscapeMarkdown lets user values open block-level Markdown structure at line start (1., * , ***, ___) R1 Netero Yes
CRF-2 Nit Author fixed (7844bce, 6a9c44c) coderd/render/escape_internal_test.go:142 NeutralisesStructure/OrderedList (and BulletList, ThematicBreak) subtests are vacuous: single-newline payloads never reach line-start position R1 Netero Yes
CRF-3 P1 Open coderd/notifications/dispatch/smtp.go:79 Escape is stripped by PlaintextFromMarkdown then interpolated raw into html.gotmpl's {{ .Labels._subject }} sink; label value still injects live HTML into the email subject line and <title> R2 Netero Yes
CRF-4 P2 Open coderd/notifications/dispatch/smtp.go:608 998-octet fold gate measures pre-encoded length, so the common mime.QEncoding.Encode path emits a single header line up to ~3.5x the RFC 5322 limit R2 Netero Yes
CRF-5 P3 Open coderd/render/escape_internal_test.go:358 HTMLFromMarkdownStillAutolinks comment says the shared renderer is unchanged, but HTMLFromMarkdown now routes through renderHTML (Safelink, IsSafeURLOverride, recover); no assertion pins the new behavior R2 Netero Yes
CRF-6 Note Open coderd/render/markdown.go:168 renderHTML panic-recovery silently substitutes escaped source for the body with no log line; branch has zero coverage and no known trigger R2 Netero Yes
CRF-7 Nit Open coderd/render/escape_internal_test.go:120 body closure duplicates suspendedBody in the same package (escape_sink_internal_test.go:14) R2 Netero Yes
CRF-8 Nit Open coderd/render/escape_sink_internal_test.go:102 permissive is declared twice in one file (also at line 64) R2 Netero Yes

Law analysis

Round 2. Effective LOC 1540 (+643 since round 1 baseline of 897), head a671d8422a.
Verdict: Split. Enforcement: Mandatory.

Slices (from law.md):

  • Slice A (already extracted as commit cf118ad): chore(scripts): exclude notification goldens from the emdash check (6 prod). Required predecessor of slice C or CI fails.
  • Slice B: fix(coderd/notifications/dispatch): encode and fold the Subject header (65 prod, 233 test). Independent risk domain: SMTP header semantics, not Markdown escaping. Different failure modes.
  • Slice C (this PR): fix(coderd): prevent markdown injection in notifications (344 prod, 896 test, 2 golden).

Mandate is on independent risk domains, not size. A reviewer signing off on Markdown escaping is not evaluating RFC 2047 encoded-word semantics or RFC 5322 folding; bundling forces one sign-off for both.

Round log

Round 1

Netero-only. 1 P1, 1 Nit. Reviewed against 676b9bb..9594a14. LOC 897 effective (no Law). Netero P1 gate: no panel, review posted with COMMENT event, panel will review after Netero findings addressed.

Round 2

Infrastructure-only (Netero + Law), no panel. Churn guard PROCEED (CRF-1, CRF-2 addressed). Law verdict Split-Mandatory: extract slice B (SMTP subject encoding) as a separate PR. Netero adds 1 P1, 1 P2, 1 P3, 1 Note, 2 Nits. Reviewed against c029869..a671d84. Posted with REQUEST_CHANGES.

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.

First-pass review only: this is Netero's mechanical scan, the full review panel has not yet looked at this PR. The panel will review after the findings below are addressed.

Nice attributes of the change: the character-class comments justify each split cleanly (inlineCritical vs blockStart vs foldStart); TestEscapableSet pins the renderer honor tables so a dependency bump cannot silently drift; and the control-value guard (user_override, service, 0, bobby-workspace, 1.5) is the right cross-check for a design that deliberately keeps _ unescaped.

Netero flagged 1 P1 and 1 Nit. Verification against HEAD reproduces Netero's <ol> case and finds three siblings from the same root cause (bullet lists with *, and thematic breaks with *** or ___); they are folded into CRF-1 as one class rather than filed separately.

None of them appears in a control label value such as user_override.

(Netero, describing the character-class rationale that CRF-1 revises.)

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/render/escape.go
Comment thread coderd/render/escape_internal_test.go Outdated
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.
@BobbyHo
BobbyHo marked this pull request as ready for review August 20, 2026 15:28
@jscottmiller

Copy link
Copy Markdown
Contributor

Implementing our own markdown escaping raises a red flag. Is there a way we can approach this where we either avoid applying the markdown->html transformation on the user-supplied values and/or use a popular library to do the escaping? In the past I've paired gomarkdown with bluemonday, though the goal there was stripping content rather than escaping. Alternatively we could inject random sentinel placeholder values that we then replace after the markdown has been rendered, though that's likely a bigger change to our rendering pipeline.

@BobbyHo

BobbyHo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Implementing our own markdown escaping raises a red flag. Is there a way we can approach this where we either avoid applying the markdown->html transformation on the user-supplied values and/or use a popular library to do the escaping? In the past I've paired gomarkdown with bluemonday, though the goal there was stripping content rather than escaping. Alternatively we could inject random sentinel placeholder values that we then replace after the markdown has been rendered, though that's likely a bigger change to our rendering pipeline.

Thanks @jscottmiller for the suggestion. I agree that implementing our own Markdown escaping isn’t ideal.

I went looking for holes in the current escaper and found a few. A simple example is a display name like Eve\n\n hidden, which produces:

<p>Name is **Eve</p><pre><code>hidden** end.
</code></pre>

I took a quick look at Bluemonday, but I don’t think it helps here. It operates on the rendered HTML, so by then x is already an and there’s no way to distinguish it from links intentionally produced by our templates. Stripping all links would break those templates. It also wouldn’t cover the plaintext output, webhook body, or inbox, none of which render HTML.

I also looked for a well-established off-the-shelf Markdown escaper, including html-to-markdown/escape, but I couldn’t find one that fits this use case. The Markdown libraries I found focus on rendering rather than escaping untrusted interpolated values. If you know of one, I’d be happy to take a look.

I do like the sentinel idea. I tried a quick prototype and it looks promising:

A random 32-character token survives both renderers unchanged in bold text, headings, link URLs, table cells, and list items. Glamour uses WithWordWrap(0), so it isn’t split.
None of the ~50 stored templates would need to change. text/template can still compare the wrapper against the real value with eq, while rendering the sentinel token.

As you mentioned, though, this would be a broader rendering-pipeline change. We’d need a resolve pass in six places, including the inbox path where rendered Markdown is stored in the DB and rendered again on read. It would also affect body_markdown in the webhook payload, which is documented and consumed outside this repo.

I’d rather not make that design decision as part of this security fix, so I’ll open a separate ticket to get broader input.

For this PR, I’m thinking we strengthen the custom escaper enough to address the security issue, then consider moving to sentinels as a follow-up if we agree that’s the better long-term approach. What do you think?

@BobbyHo
BobbyHo requested a review from jscottmiller August 20, 2026 22:58

jscottmiller commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review: six items, three of them reproduced end to end

I ran a multi-perspective review of this branch and then validated the significant findings against the real render pipeline rather than by inspection. Items 1-3 are confirmed with output; 4-6 are read from the code and the stdlib behavior.

The design here is good, and I want to be specific about that before the findings: the TestEscapableSet renderer-pinning test is the strongest guard in the change, restricting _/*/-/. to leading position to protect eq label values is exactly the right call, and I independently confirmed the two things most likely to break silently are genuinely unaffected. Action URLs are rendered at enqueue time against the unescaped payload, so every {{base_url}}/@{{.Labels.workspace_owner_username}}/... action is untouched, and all 48 SMTP plus webhook goldens are byte-identical. The "no golden file changes" claim in the description holds.

Items 1-3 are cases where the escaper reasons about markdown markers while the renderer has a sink the marker analysis doesn't cover.


1. BLOCKER: backtick is escaped nowhere, and the fence info string is a live HTML sink

inlineCritical omits `. gomarkdown splices a fenced block's info string into class="language-…" with no escaping (html/renderer.go, appendLanguageAttr). SkipHTML does not apply, because this is a CodeBlock node, not an HTMLBlock. A " in the value terminates the attribute and a > closes the tag.

Interpolating an untrusted label into a normal body template produces:

<pre><code class="language-">2\<a/href="https://phish.example">Click here to reactivate your account
</code></pre>

<img/src=x/onerror=alert(1)> and a full-viewport <div/style="position:fixed;…"> overlay render identically. / works as an attribute separator, so no space is needed, and &#40;/&#41; sidestep the paren escaping. The \ the escaper adds before < lands inside the attribute value as literal text and does not help.

The fence needs block position, so the vector requires a value containing a blank line. That is satisfied by the ingress the PR description names.

Reachability, most severe path

UserRealNameValid (codersdk/name.go:90-97) enforces only "≤128 bytes, no leading or trailing whitespace". Newlines, <, " and backticks all pass, and there is no DB CHECK on users.name. putUserProfile gates only the username field behind update_users (coderd/users.go:901), so a plain member can set their own display name on PUT /users/me/profile. A working payload is about 40 bytes.

coderd/users.go:1082 then puts targetUser.Name into suspended_account_user_name, migration 000568:29 interpolates it into the body, and findUserAdmins (coderd/users.go:1108) sends it to every user admin and owner. Same shape for activated and deleted.

That is member privilege to HTML injection in an admin's mail client. Two more paths, both template-admin privilege but much wider blast radius: template_version_message (validate:"lt=1048577" only, codersdk/organizations.go:128) reaches the owner of every auto-updating workspace on the template, and the deprecation message (no validate tag, codersdk/templates.go:268) reaches the owner of every workspace on it.

The OIDC and GitHub claim paths bypass UserRealNameValid entirely and only call NormalizeRealUsername (trim plus truncate), so they are strictly weaker, not stronger.

Fix: add ` to inlineCritical. I verified both renderers honor \` and that the escaped payload renders inert with no backslash leakage. This also fixes the current leak of <code>config\[0\]</code> in code spans.

2. BLOCKER: : is in no character class

: is never escaped and | is escaped only in leading position, so a GFM delimiter row starting with : survives byte-identical:

IN   "x | y\n:-: | :-:\n1 | 2"
HTML <th align="center">The account belongs to **x</th> …
     <td align="center">2** and was suspended by <strong>rob</strong>.</td>

Definition lists too, and those need only a single newline:

<dt>URGENT Account compromised</dt><dd>Call 555-1234** and it was suspended by <strong>rob</strong>.</dd>

Not HTML injection, but full control of message structure with the trusted sentence absorbed and reframed. <table> is already in the test's own structuralTags, so this fails the stated contract. The existing Table case passes only because that variant starts with -; <dl>, <dt> and <dd> are missing from structuralTags altogether.

Fix: add : to foldStart, not blockStart. glamour does not honor \:, so escaping would leak a literal backslash into the plaintext part. Folding closes both the table and the definition-list case; I verified this.

3. HIGH: 4+ space indentation is indented code, where backslashes are not honored

escapeLine treats arbitrarily deep indentation as leading position, but at 4+ spaces the line is an indented code block:

IN   "Eve\n\n    a [b] c"
HTML <p>…Eve</p><pre><code>a \[b\] c** and it was suspended by **rob**.
     </code></pre>

Structure break plus the escaper's own backslashes rendered visibly to the recipient. Fix: cap the emitted leading-space run at 3. Verified clean in both renderers.


4. Regression introduced here: Safelink panics on an empty destination

needSkipLink calls parser.IsSafeURL, which does url[:nPath] before its nLink >= nPath guard, so [our docs]() panics with a slice-bounds error. Before this change IsSafeURL was never called. This hits HTMLFromMarkdown as well, so it is reachable outside notifications through OIDCConfig.SignupsDisabledText (coderd/userauth.go:1790). renderHTML has no recover(), unlike InnerTextFromMarkdown.

Fix: set html.RendererOptions.IsSafeURLOverride to a length-safe wrapper, and mirror the defer recover() from InnerTextFromMarkdown.

Related and worth a comment on renderHTML: Safelink silently drops fragment and bare relative destinations. [a](#anchor) and [a](docs/x.md) now render without an anchor, while /path, ./path, mailto: and http(s):// still work. No shipped template is affected, but a future template author gets silent link loss with no error.

5. Subject still takes an RFC 2047 encoded-word injection

mime.QEncoding.Encode returns pure-ASCII input unchanged, and nothing in the pipeline escapes = mid-line or ?, so an attacker-supplied =?utf-8?b?…?= survives escaping, plaintext rendering and encodeHeaderValue verbatim, and the MUA decodes it. The attacker gets the displayed subject line back. In scope for the header fix this PR claims.

Also: encodeHeaderValue never folds. splitWord separates encoded-words with a space rather than CRLF+SP, and the ASCII path returns untouched, so ~200 non-ASCII runes produce a single 1400+ character header line, past the RFC 5322 998-octet limit.

6. The comment at notifier.go:255-256 is not true

The dispatcher still receives the unescaped payload: it needs the original values and escapes at its own sinks.

smtp/html.gotmpl is rendered with text/template and splices {{ .Labels._subject }} (:6, :14) and {{ .UserName }} (:17) with no escaping, and _subject has been through PlaintextFromMarkdown, which strips the escaping back out. Verified:

ESCAPED TITLE : Workspace builds failed for template "\<img src=x onerror=alert\(1\)>…"
<title>Workspace builds failed for template "<img src=x onerror=alert(1)><a href="https://evil.example">Click</a>"</title>
<p>Hi <img src=x onerror=alert(1)><a href="https://evil.example">Click</a>,</p>

The sink itself is pre-existing and I suspect it is what "a follow-up PR completes the fix" refers to, which is fine. Its cross-user reach is limited: .UserName is always the recipient's own name (notifications.sql:9), so changing another user's requires user-admin, and the only unconstrained value reaching _subject is custom_title, which is self-send only. Every other title resolves to a NameValid slug, a UUID or an enum. So this is not urgent, but the comment asserting a guarantee that does not exist should not ship with a security fix. Either drop the second sentence or state what is actually true, that the dispatcher gets the unescaped payload because the webhook contract surfaces the enqueued values verbatim.

Method

Reviewed by a panel of three reviewers scoped to separate axes (markdown parser behavior, SMTP and dispatch sinks, template compatibility and regressions), then the corroborated findings were validated against the real pipeline in a scratch package that called EscapedForMarkdown, notifications/render.GoTemplate, HTMLFromNotificationMarkdown and PlaintextFromMarkdown directly. Every HTML fragment quoted above is real output. Reachability was traced separately against the migrations, the enqueue sites and the validators. The scratch package was deleted; nothing in the branch was modified.

Two corrections to my own earlier working notes, in the interest of not overstating: my first email-shell repro used template_display_name in a title, which no longer ships, as 000316 replaced that title with a static string. And the fence vector does not fire from a single newline, it needs a blank line, which narrows it to values that permit one.

One item I could not settle read-only: .Data.replacements keys on the Prebuilt Workspace Resource Replaced template are raw Terraform addresses and go to all template admins, and escapeValue does not escape map keys. Whether a for_each index key derived from a workspace parameter can carry a newline into the address needs a live terraform plan. If it can, that is another member-to-admin path, and escaping Data keys is cheap insurance either way.


🤖 Generated with Coder Agents on behalf of @jscottmiller

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.
@BobbyHo BobbyHo changed the title fix(coderd): prevent markdown injection in notifications fix: prevent markdown injection in notifications Aug 24, 2026
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.

BobbyHo commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

All six items are addressed, plus the .Data map key question you left open. Pushed as b42dcf7c0a.

# Finding What landed
1 Fence info string is an unescaped HTML sink Backtick joins inlineCritical
2 : is in no character class : joins foldStart
3 4+ space indentation is an indented code block Leading run truncated to three
4 Safelink panics on an empty destination Length-safe IsSafeURLOverride, plus recover
5 Encoded-word injection, unfolded long headers Explicit encoded-words joined with CRLF
6 notifier.prepare comment is not true Rewritten to name the sinks that are bare
open item .Data map keys are not escaped escapeValue escapes nested keys

Your foldStart call on item 2 was right and my own notes had it wrong: I had : in a leading-escape class, which closes the same constructs but leaks a backslash, because glamour does not honor \:. Folding also closed the table and definition-list cases in glamour, which I had expected to need separate work.

Your open item is settled on the escaper side: an unescaped key renders a live <a href> through the shipped resource-replacements body. Whether a for_each index key can carry an attacker-controlled address into it still needs a real terraform plan. This is the one change that moves a golden.

Two corrections.

  • IsSafeURLOverride is a field on Renderer and Parser, not on RendererOptions.
  • Backtick in inlineCritical does not fix the <code>config\[0\]</code> leak. That one comes from a code span the template opens, `{{$volume.path}}` in the workspace out-of-disk body, so escaping the value's backticks leaves it unchanged. Verified. If you meant a value supplying its own backticks, that case is handled.

Also closed from a parallel sweep: MathJax spans, and a first line that is a tilde fence or a === underline, which let a display name of ~~~ render the entire Subject, <title> and heading as an empty string. notificationExtensions is now an allowlist of the grammar templates actually use, which drops Tables, DefinitionLists and MathJax with no rendering change.

A repro note on item 1, since it cost me two attempts: the payload needs a line after the closing fence. Otherwise the template's trailing text lands on that line, the fence stops being a fence, and it degrades to an inert inline <code>.

One residual ships documented, pinned by a test that fails if it closes: escapes are inert inside a template-supplied code span. That one is unfixable before rendering by construction, since whether an escape is honored is decided by a context the escaper never sees.

Your original objection stands. Three of the sixteen findings resisted escaping outright, and each needed a different non-escaping mechanism to close: truncation for indentation, escaping-with-a-visible-artifact for the first line, and nothing at all for the code span. Placeholder substitution closes all three with one mechanism. I would ship this as the short-term fix and file that as a tracked follow-up, but I am open on the sequencing.

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.
@BobbyHo

BobbyHo commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

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.

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

Round 2. CRF-1 (P1) and CRF-2 (Nit) both landed; verified against the current code, thanks. This round is Netero + Law only, no panel: Law's mandatory-split verdict gates panel review, so this is a COMMENT with changes recommended before the panel opens.

Netero: 1 P1, 1 P2, 1 P3, 1 Note, 2 Nits.

The P1 (CRF-3) is a deferral without a ticket. Dispatcher runs PlaintextFromMarkdown on the rendered title, which honors exactly the backslashes the escaper just added, and then assigns the plaintext into payload.Labels["_subject"] on the unescaped payload; smtp/html.gotmpl interpolates {{ .Labels._subject }} twice with no | html. The notifier.prepare comment now names this bare sink honestly, but that is not the same as fixing it. Per accepted-gap rules this needs an explicit human decision: file the tracking ticket for the follow-up work, or state the acceptance explicitly here. A follow-up PR completes the fix is a promise, not a deferral.

Law, effective 1540 LOC (+643 since round 1): Split, Mandatory. Extract slice B (SMTP Subject encoding, smtp.go + smtp_internal_test.go + smtp_test.go, ~65 prod / ~233 test) as a separate PR. Slice A (emdash exclusion) is already isolated as commit cf118ad4d2. Slice C (escape + wiring + grammar allowlist) stays whole. Mandate is on independent risk domains, not size: RFC 2047 encoded-word / RFC 5322 folding is a different failure surface (mangled subject lines in production mail) than Markdown escaping (backslash leaks, unescaped values). Bundled, one sign-off covers two review questions that do not overlap.

Extracting slice B naturally moves CRF-4 (P2) into its own PR where the RFC-correctness review can focus on it. CRF-3 needs to close in slice C regardless of the split.

Good in the diff: TestNotificationExtensionsDropUnusedGrammar pinning the exact reason each extension is dropped; TestEscapeMarkdownResiduals locking in what the escaper cannot fix pre-render; the liveness guard in checkNeutralised that fails any row that would have passed the vacuous check.

"a follow-up PR completes the fix" is a promise, not a deferral.

(Netero, on the Subject sink.)

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/notifications/notifier.go Outdated
Comment thread coderd/notifications/dispatch/smtp.go Outdated
Comment thread coderd/render/escape_internal_test.go Outdated
Comment thread coderd/render/markdown.go
Comment thread coderd/render/escape_internal_test.go Outdated
Comment thread coderd/render/escape_sink_internal_test.go Outdated
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]
Fold the four restatements of the eq-control-value rationale into one, drop
the doc comments that paraphrase the code they sit above, and cut edit
history from the test comments. Keeps the facts a reader cannot recover
from the code: the fence info-string sink, why =~: are folded rather than
escaped, the IsSafeURL bounds bug, and the code-span residual.

Comments only, no behavior change.
Cut every test doc comment to one or two lines and trim the inline comments
to match. What was dropped is already stated where it acts: the character
class rationale on the consts in escape.go, and the re-derive instruction in
the assertion failure messages.

Comments only, no behavior change.
@BobbyHo
BobbyHo merged commit 9e8075d into main Aug 25, 2026
28 checks passed
@BobbyHo
BobbyHo deleted the coder-plat-273-sec-93 branch August 25, 2026 15:12
@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.36 Backport PR targeting release/2.36 labels Aug 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

backport/v2.35 Backport PR targeting release/2.35 backport/v2.36 Backport PR targeting release/2.36 backport

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants