Ruby on Rails · Report #3994016
Action Text to_markdown: <code>/<pre> content escapes its delimiter, letting a stored body inject arbitrary Markdown
How it was found
Summary
ActionText::Content#to_markdown escapes text nodes so that a stored rich text body cannot
turn into Markdown syntax. Text whose immediate parent is <code> or <pre> is exempt from
that escaping (MarkdownConversion::SKIP_ESCAPING_PARENTS) and is instead kept inert by the
delimiter its visitor wraps around it. The converter never checks that the delimiter it emits
actually delimits anything, and there are two ways for stored content to make it stop working:
-
inline_code(markdown_conversion.rb:368-376) widens the backtick run and pads when the content starts or ends with a backtick, but it does not consider blank lines. A CommonMark code span cannot contain a blank line: the blank line ends the paragraph, so the span is never closed and everything after it is read as ordinary Markdown. -
visit_pre(:180-184) emits a ``` fence, which only opens a code block at the start of a line.visit__heading(:197-199) splices that block output into a line it has already begun, so the fence opens nothing and the content it was meant to fence is live.visit_summary(:247-249),visit_tr(:237-245) andvisit__table_header_row(:282-287) do the same thing; the attached test covers all four.
Either way a rich text body can emit link syntax with a URI scheme that visit_a (:222-229)
refuses for a real <a href> via Rails::HTML::Sanitizer.allowed_uri?, and raw < / > that
escape_markdown_text escapes everywhere else (existing test: "text escaping: angle bracket
(HTML block)").
Affected: main at 152594147f (8.2.0.alpha). to_markdown has not shipped in a release, so no
released version is affected.
This is not the <action-text-markdown> path from my earlier report #3727743. That path is
still closed and I re-checked it before filing; see "Why this is not covered by #3727743".
Why the payload gets that far
has_rich_text assigns through ActionText::Content, which canonicalizes but does not
sanitize; sanitization happens at render time in ContentHelper. to_markdown therefore runs
against the stored body. code and pre are on the default safelist anyway, so the payload
survives either path.
The blank line does not have to be a literal newline in the stored HTML. visit_br emits
"\n", and <br> is how Trix represents a newline (noted in the comment above visit_div),
so a <br><br> pair reaches the same output:
<div><code>x<br><br>[click](javascript:alert(1))</code></div>
Steps to reproduce
Drop the attached markdown_conversion_poc_test.rb into actiontext/test/unit/ and run it
against a clean checkout of 152594147f:
cd actiontext
bundle exec ruby -Itest test/unit/markdown_conversion_poc_test.rb
Result on pristine main (full output in RESULTS-poc-test.txt):
12 runs, 25 assertions, 8 failures, 0 errors, 0 skips
The control tests pass, so the escaping works for the same payload in ordinary text. Each failing test asserts an invariant of the emitted Markdown and prints the output: a code span may not contain a blank line, and a ``` run that does not begin its line must be closed on the same line.
ActionText::Content#to_markdown on 152594147f, stored body on the left:
"<p>[click](javascript:alert(1))</p>" (control)
=> "\\[click\\](javascript:alert(1))"
"<div><code>x<br><br>[click](javascript:alert(1))</code></div>"
=> "`x\n\n[click](javascript:alert(1))`"
"<h1><code>x<br><br>[click](javascript:alert(1))</code></h1>"
=> "# `x\n\n[click](javascript:alert(1))`"
"<blockquote><code>x<br><br>[click](javascript:alert(1))</code></blockquote>"
=> "> `x\n> \n> [click](javascript:alert(1))`"
"<h1><pre>[click](javascript:alert(1))</pre></h1>"
=> "# ```\n[click](javascript:alert(1))\n```"
"<p><code>x<br><br><img src=x onerror=alert(1)></code></p>"
=> "`x\n\n<img src=x onerror=alert(1)>`"
attachments/standalone/ reproduces the same outputs without a Rails app: it loads the four
upstream files unmodified in a container with nokogiri, activesupport and rails-html-sanitizer.
Why this is not covered by #3727743
The fix for my earlier report #3727743 (2191ca0d6c) unwraps user-supplied
<action-text-markdown> during canonicalization and adds this note to node_to_markdown:
NOTE: text inside `<action-text-markdown>` elements is emitted without escaping, so this
method is not safe for untrusted content. Convert user-supplied markup through
ActionText::Content, which strips those elements while canonicalizing.
The payload in this report meets that stated precondition and still leaks. Two tests in the attached file document the separation, and both pass on pristine main:
- "the payload satisfies the precondition stated by the fix for #3727743" - the body carries no
marker tag, and
Content.new(html).to_htmlis identical with and without canonicalization, so the earlier fix is a no-op on it. - "the marker-tag path itself is still closed, including foreign content" - I re-checked that
root cause before filing. The plain element and the
<svg>,<math>and<template>foreign content variants are all unwrapped correctly, so this is not another way in through that door.
The code at fault is older than that report and landed for unrelated reasons: inline_code and
code_fence in #56871 (2026-02-24), visit_pre and visit__heading in #56858 (2026-02-24).
2191ca0d6c touched neither, and its regression tests are all about the marker tag.
That the generated Markdown must not contain a link with a rejected scheme is an invariant this
module already defends with seven dedicated tests (markdown_conversion_test.rb:569-595:
<a href> with javascript: obfuscated by leading space, tab, entity encoding, entity-encoded
colon, case, and newline). This report reaches that same output without touching <a href>.
On fixing it
A patch is attached (SUGGESTED-PATCH.diff), not as a recommendation but as evidence that the
two shapes are separable from intended behaviour: with it applied the attached tests pass and
markdown_conversion_test.rb stays at 174 runs, 217 assertions, 0 failures, so nothing the
existing suite pins about the current output depends on the broken delimiters. What the real
fix should look like is yours to decide.
Environment
rails/rails main @ 152594147f2dff25093829aebe8aa8eecf7de286
ruby 3.4, nokogiri 1.19.1, rails-html-sanitizer 1.7.0
Impact
A user who may post rich text controls the structure of the Markdown that to_markdown
generates for that body, not just its text. From inside a <code> or <pre> element they can
start a new Markdown block and emit link syntax, headings, HTML blocks, or anything else, none
of which the escaping is supposed to allow. The link they emit never passes the URI scheme
check that visit_a applies to a real <a href>, so javascript: and friends survive.
What that turns into depends on what consumes the Markdown.
Renderer defaults, nothing enabled or disabled (RENDERERS.txt), for the body
<div><code>x<br><br>[click](javascript:alert(1))</code></div>:
redcarpet 3.6.1 <p>`x</p><p><a href="javascript:alert(1)">click</a>`</p>
kramdown 2.5.2 <p>`x</p><p><a href="javascript:alert(1)">click</a>`</p>
commonmarker 2.10.0 <p>`x</p><p><a href="">click</a>`</p>
and for <p><code>x<br><br><img src=x onerror=alert(1)></code></p>:
redcarpet 3.6.1 <p>`x</p><p><img src=x onerror=alert(1)>`</p>
kramdown 2.5.2 escaped
commonmarker 2.10.0 <!-- raw HTML omitted -->
On an application that renders the generated Markdown with Redcarpet or kramdown at their defaults, that is stored XSS: one user saves the payload and it executes in the browser of everyone who views the rendered output. On cmark-gfm the scheme is stripped, but a link element is still manufactured where Rails promised an escaped literal, which is content and structure injection into whatever consumes the document, such as a mail body or an export.
The guarantee that breaks here is Rails', not the renderer's. escape_markdown_text escapes
[, ], < and > in every other text node, and visit_a refuses schemes that
Rails::HTML::Sanitizer.allowed_uri? rejects, precisely because the converter cannot know what
will render its output. The <code> path bypasses both.
On the "not HTML safe" note in the docs: it is a warning that the output is Markdown rather than HTML, and it did not stop #3727743 from being treated as a defect and fixed. If that note were sufficient, the scheme check in this module would have nothing to protect.
No released version of Rails is affected; to_markdown exists only on main.