Ruby on Rails · Report #3727743
URI scheme validation bypass in ActionText `to_markdown` via user-supplied `<action-text-markdown>` marker tag
How it was found
The markdown converter treats every <action-text-markdown> element as trusted internally-generated markup and emits its text verbatim. Content#to_markdown only creates that tag when serializing attachments, but the visitor does not distinguish internal nodes from user-supplied ones. A rich-text body containing a literal <action-text-markdown> tag therefore bypasses the URI-scheme validation that was added for normal links.
the bad lines
actiontext/lib/action_text/markdown_conversion.rb:80
SKIP_ESCAPING_PARENTS = %w[ action-text-markdown code pre ].freeze
actiontext/lib/action_text/markdown_conversion.rb:96-97
elsif skip_markdown_escaping?(node)
node.content
actiontext/lib/action_text/markdown_conversion.rb:226-228
def visit_action_text_markdown(_node, child_values)
join_children(child_values)
end
pre and code are in the same skip list but their visitors wrap output in fenced code, so a link payload inside them becomes inert code. visit_action_text_markdown emits raw, which is why this specific marker is the bypass.
proof of concept
Save as actiontext/test/unit/markdown_passthrough_bypass_test.rb:
require "test_helper"
class ActionTextMarkdownPassthroughBypassTest < ActiveSupport::TestCase
test "user-supplied <action-text-markdown> emits markdown link bypassing URI scheme validation" do
payload = "<action-text-markdown>[click](javascript:alert(1))</action-text-markdown>"
markdown = ActionText::Content.new(payload).to_markdown
refute_match(/\(javascript:/, markdown,
"expected URI scheme validation to reject javascript: URI, got: #{markdown.inspect}")
end
end
Run from the repo root:
cd actiontext && bin/test test/unit/markdown_passthrough_bypass_test.rb
Observed:
Failure:
expected URI scheme validation to reject javascript: URI, got: "[click](javascript:alert(1))"
Control: the existing remote-image hardening tests still pass:
cd actiontext && bin/test test/unit/markdown_conversion_test.rb -n '/RemoteImage attachment with disallowed URI scheme|RichText#to_markdown/'
# 5 runs, 5 assertions, 0 failures, 0 errors
suggested fix
Strip user-supplied <action-text-markdown> nodes before to_markdown creates its own internal markers. Drop-in diff:
--- a/actiontext/lib/action_text/content.rb
+++ b/actiontext/lib/action_text/content.rb
@@ -146,6 +146,11 @@ module ActionText
end
def to_markdown(attachment_links: false)
+ # Prevent user-supplied <action-text-markdown> nodes from being treated
+ # as trusted internal markdown passthrough by the markdown converter.
+ fragment.find_all("action-text-markdown").each do |node|
+ node.replace(node.children)
+ end
render_attachments(with_full_attributes: false) { |attachment|
ActionText::HtmlConversion.create_element("action-text-markdown").tap do |node|
node.content = attachment.to_markdown(attachment_links: attachment_links)
fragment.find_all is the same Nokogiri idiom already used at content.rb:56 and :210. After this change the PoC test passes and the existing attachment-markdown tests are unaffected.
Impact
Any consumer of ActionText::RichText#to_markdown / Content#to_markdown output (preview pane, email digest, federated mirror, secondary markdown renderer) can receive an attacker-controlled javascript:, data:, or other unsafe-scheme markdown link from a user who can author rich text. The URI safety boundary added by PRs #56871 and #56909 to normal <a> and remote-image attachment paths is undone for markdown export when the user wraps the payload in <action-text-markdown>.
The HTML rendering path (Content#to_s) is not directly affected; the marker tag is stripped and the payload renders as inert text. The bypass is specific to markdown export.