XSS via unsafe URI scheme in Markdown/link rendering (javascript:, data: URIs, etc.)

HIGH
rails/rails
Commit: 367f7df55d1d
Affected: Rails 8.1.x prior to the fix introduced in this commit (pre-8.1.3)
2026-04-05 12:09 UTC

Description

This commit is a genuine security fix. Previously, the Action Text Markdown link generation path (markdown_link) did not validate URI schemes, allowing attacker-controlled URLs with dangerous schemes (e.g., javascript:, data:text/html, etc.) to pass through in Markdown representations and potentially render unsafe links or images. The patch adds a URI scheme validation guard via Rails::HTML::Sanitizer.allowed_uri? inside MarkdownConversion.markdown_link, rendering disallowed URIs as escaped bracketed text (e.g., \[title\]) instead of emitting a link. It also centralizes image/link formatting through an image: keyword argument and updates several call sites (RemoteImage, ActiveStorage::Blob) to route through the same validation. Tests were added to cover allowed data:image URIs and the no-attachment fallback behavior, strengthening defense-in-depth against XSS in Action Text Markdown rendering.

Proof of Concept

Proof-of-concept (exploit path before fix): - Reproduction using the MarkdownConversion path that the fix guards: # Before the fix, markdown_link did not validate the URL scheme markdown = ActionText::MarkdownConversion.markdown_link("Click me", "javascript:alert(1)") # Expected (before fix): "[Click me](javascript:alert(1))" — a real Markdown link that browsers may execute if clicked # In a view, this renders as: <a href="javascript:alert(1)">Click me</a> and could execute XSS when clicked by a user - After the fix (for comparison), the same input yields escaped text instead of a link: markdown = ActionText::MarkdownConversion.markdown_link("Click me", "javascript:alert(1)") # Expected after fix: "\\[Click me\\]" (bracketed, non-link text) - Additional flow: when using an actual RemoteImage/attachment path, the renderer now routes through markdown_link(image: true) and will similarly render escaped text for disallowed schemes instead of emitting a link. This prevents an attacker-controlled URL from becoming an actionable anchor or image source. Prerequisites/conditions: - Rails app with ActionText and RemoteImage/ActiveStorage integration enabled. - An attachment or markdown representation that sources a user-provided URL/URL-like attribute (e.g., action-text-attachment with url attribute). - A browser context where clicking a javascript: URL would execute code (XSS).

Commit Details

Author: Mike Dalessio

Date: 2026-03-12 18:20 UTC

Message:

Validate URI scheme in Action Text markdown link conversion (#56909) * Validate URI scheme in Action Text `markdown_link` and `RemoteImage` In the HTML rendering pipeline, `SafeListSanitizer` strips dangerous URI schemes (`javascript:`, `data:text/html`, etc.) from `<img src>` attributes. But in the Markdown pipeline, `RemoteImage#attachable_markdown_representation` called `MarkdownConversion.markdown_link` with no URI scheme validation, allowing crafted `<action-text-attachment>` elements to pass URLs through unchecked. Also relevant: `MarkdownConversion` already calls `allowed_uri?` before emitting an anchor link, so there was an inconsistency even within the Markdown path. Add a `Rails::HTML::Sanitizer.allowed_uri?` check to `markdown_link`. When the URI scheme is disallowed, return the escaped title wrapped in escaped brackets (`\[title\]`) instead of emitting a link. Add an `image:` keyword argument to `markdown_link` that prepends `!` for image syntax (`![title](url)`), centralizing the image prefix logic so callers don't need to manage it themselves. Update `RemoteImage#attachable_markdown_representation` to delegate to `markdown_link(caption, url, image: true)`. Update `ActiveStorage::Blob#attachable_markdown_representation` to use `markdown_link(title, url, image: image?)` and escape brackets in the no-attachment-links fallback. The `image:` kwarg eliminates a duplicate `allowed_uri?` check that was previously needed in `RemoteImage` to avoid prepending `!` to the fallback text. Now `markdown_link` is the single source of truth for URI validation, link formatting, and the image prefix. The `ActiveStorage::Blob` path uses `url_for(self)` which generates safe system URLs, but the check in `markdown_link` provides defense-in-depth for all callers. * Update `Attachment#to_markdown` rdoc for disallowed URI schemes Document that remote images with a disallowed URL scheme render as escaped bracketed text rather than image links. * Add test coverage for allowed `data:image` URIs in `markdown_link` Verify that `data:image/png;base64,...` URIs pass the `allowed_uri?` check and produce proper image links, both at the `markdown_link` unit level and through the `RemoteImage` integration path.

Triage Assessment

Vulnerability Type: XSS / URI scheme validation

Confidence: HIGH

Reasoning:

The commit adds a URI scheme validation check in the Markdown link generation path and ensures disallowed schemes (e.g., javascript:, data:text/html) are rendered as escaped text instead of links. This closes an input validation vulnerability in Action Text Markdown/RemoteImage rendering that could allow attacker-controlled URLs to bypass sanitization. It also centralizes image/link formatting and includes tests for data URIs, further strengthening defense-in-depth against XSS via crafted markdown links.

Verification Assessment

Vulnerability Type: XSS via unsafe URI scheme in Markdown/link rendering (javascript:, data: URIs, etc.)

Confidence: HIGH

Affected Versions: Rails 8.1.x prior to the fix introduced in this commit (pre-8.1.3)

Code Diff

diff --git a/actiontext/lib/action_text/attachables/remote_image.rb b/actiontext/lib/action_text/attachables/remote_image.rb index 49243681e81ee..5c85fa27a2fa0 100644 --- a/actiontext/lib/action_text/attachables/remote_image.rb +++ b/actiontext/lib/action_text/attachables/remote_image.rb @@ -45,7 +45,7 @@ def attachable_plain_text_representation(caption) end def attachable_markdown_representation(caption, attachment_links: false) - "!#{MarkdownConversion.markdown_link(caption || "Image", url)}" + MarkdownConversion.markdown_link(caption || "Image", url, image: true) end def to_partial_path diff --git a/actiontext/lib/action_text/attachment.rb b/actiontext/lib/action_text/attachment.rb index 93105b39e863c..c8c28727a8f8c 100644 --- a/actiontext/lib/action_text/attachment.rb +++ b/actiontext/lib/action_text/attachment.rb @@ -122,12 +122,12 @@ def to_plain_text # # attachable = ActiveStorage::Blob.find_by filename: "racecar.jpg" # attachment = ActionText::Attachment.from_attachable(attachable) - # attachment.to_markdown # => "[racecar.jpg]" + # attachment.to_markdown # => "\\[racecar.jpg\\]" # # Use the `caption` when set: # # attachment = ActionText::Attachment.from_attachable(attachable, caption: "Vroom vroom") - # attachment.to_markdown # => "[Vroom vroom]" + # attachment.to_markdown # => "\\[Vroom vroom\\]" # # When +attachment_links+ is true and a rendering context is available (e.g., controller or # mailer action), ActiveStorage blob attachments generate Markdown links with URLs. @@ -138,11 +138,16 @@ def to_plain_text # # Non-image blob # attachment.to_markdown(attachment_links: true) # => "[report.pdf](http://example.com/rails/active_storage/blobs/...)" # - # Remote images always render as Markdown links regardless of +attachment_links+: + # Remote images always render as Markdown image links when the URL scheme is allowed: # # content = ActionText::Content.new('<action-text-attachment content-type="image/jpeg" url="https://example.com/photo.jpg" caption="A photo"></action-text-attachment>') # content.to_markdown # => "![A photo](https://example.com/photo.jpg)" # + # Remote images with a disallowed URL scheme render as escaped bracketed text: + # + # content = ActionText::Content.new('<action-text-attachment content-type="image/jpeg" url="data:text/html,PAYLOAD" caption="Click"></action-text-attachment>') + # content.to_markdown # => "\\[Click\\]" + # # The presentation can be overridden by implementing the `attachable_markdown_representation` # method: # diff --git a/actiontext/lib/action_text/engine.rb b/actiontext/lib/action_text/engine.rb index a709c47bda585..7bf57b89a93d8 100644 --- a/actiontext/lib/action_text/engine.rb +++ b/actiontext/lib/action_text/engine.rb @@ -68,13 +68,9 @@ def attachable_markdown_representation(caption = nil, attachment_links: false) raise ArgumentError, "attachment_links requires a rendering context" unless renderer url = renderer.url_for(self) - if image? - "!#{MarkdownConversion.markdown_link(title, url)}" - else - MarkdownConversion.markdown_link(title, url) - end + MarkdownConversion.markdown_link(title, url, image: image?) else - "[#{MarkdownConversion.escape_markdown_text(title)}]" + "\\[#{MarkdownConversion.escape_markdown_text(title)}\\]" end end diff --git a/actiontext/lib/action_text/markdown_conversion.rb b/actiontext/lib/action_text/markdown_conversion.rb index bec97460b4669..8de4f2bfb7c96 100644 --- a/actiontext/lib/action_text/markdown_conversion.rb +++ b/actiontext/lib/action_text/markdown_conversion.rb @@ -28,14 +28,30 @@ def node_to_markdown(node) end.strip end - # Returns a Markdown link: +[title](url)+. Escapes brackets and backslashes - # in +title+, and percent-encodes characters in +url+ that would break the - # link syntax. + # Returns a Markdown link: +[title](url)+. + # + # Escapes metacharacters in +title+, and percent-encodes characters in +url+ that would break + # the link syntax. # # MarkdownConversion.markdown_link("photo", "https://example.com/photo_(large).png") # # => "[photo](https://example.com/photo_%28large%29.png)" - def markdown_link(title, url) - "[#{escape_markdown_text(title)}](#{encode_href(url)})" + # + # Pass <tt>image: true</tt> to produce an image link (+![title](url)+). + # + # MarkdownConversion.markdown_link("photo", "https://example.com/photo.png", image: true) + # # => "![photo](https://example.com/photo.png)" + # + # If the URI scheme is not allowed (per +Rails::HTML::Sanitizer.allowed_uri?+), returns the + # escaped title wrapped in escaped brackets (+\[title\]+). + # + # MarkdownConversion.markdown_link("click", "javascript:alert(1)") + # # => "\\[click\\]" + def markdown_link(title, url, image: false) + if Rails::HTML::Sanitizer.allowed_uri?(url) + "#{"!" if image}[#{escape_markdown_text(title)}](#{encode_href(url)})" + else + "\\[#{escape_markdown_text(title)}\\]" + end end # Backslash-escapes CommonMark metacharacters in +text+ so they are treated diff --git a/actiontext/test/unit/markdown_conversion_test.rb b/actiontext/test/unit/markdown_conversion_test.rb index b61320f29ca05..7a936a5c32ddd 100644 --- a/actiontext/test/unit/markdown_conversion_test.rb +++ b/actiontext/test/unit/markdown_conversion_test.rb @@ -3,6 +3,30 @@ require "test_helper" class ActionText::MarkdownConversionTest < ActiveSupport::TestCase + test "escape_markdown_text escapes metacharacters" do + assert_equal '\\*\\*bold\\*\\* and \\[link\\](url)', ActionText::MarkdownConversion.escape_markdown_text("**bold** and [link](url)") + end + + test "markdown_link escapes title text and encodes URL" do + assert_equal '[\\*\\*bold\\*\\*](https://example.com/a%20b)', ActionText::MarkdownConversion.markdown_link("**bold**", "https://example.com/a b") + end + + test "markdown_link with disallowed URI scheme returns escaped title" do + assert_equal "\\[click here\\]", ActionText::MarkdownConversion.markdown_link("click here", "javascript:alert(1)") + end + + test "markdown_link with image: true returns image syntax" do + assert_equal "![photo](https://example.com/photo.png)", ActionText::MarkdownConversion.markdown_link("photo", "https://example.com/photo.png", image: true) + end + + test "markdown_link with image: true and disallowed URI scheme returns escaped title" do + assert_equal "\\[Image\\]", ActionText::MarkdownConversion.markdown_link("Image", "data:text/html,PAYLOAD", image: true) + end + + test "markdown_link with allowed data:image URI produces image link" do + assert_equal "![photo](data:image/png;base64,abc)", ActionText::MarkdownConversion.markdown_link("photo", "data:image/png;base64,abc", image: true) + end + # --- Text tests --- test "plain text passes through unchanged" do @@ -723,6 +747,20 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase ) end + test "RemoteImage attachment with allowed data:image URI renders as image link" do + assert_converted_to( + "![Image](data:image/png;base64,abc)", + '<action-text-attachment content-type="image/png" url="data:image/png;base64,abc"></action-text-attachment>' + ) + end + + test "RemoteImage attachment with disallowed URI scheme omits the link" do + assert_converted_to( + "\\[Image\\]", + '<action-text-attachment content-type="image/jpeg" url="data:text/html,DANGEROUS_PAYLOAD"></action-text-attachment>' + ) + end + test "RemoteImage attachment without caption falls back to Image alt text" do assert_converted_to( "![Image](https://example.com/photo.jpg)", @@ -746,7 +784,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="Captioned"></action-text-attachment>) - assert_converted_to("[Captioned]", html) + assert_converted_to("\\[Captioned\\]", html) end test "Blob image with attachment_links: true uses caption" do @@ -763,7 +801,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}"></action-text-attachment>) - assert_converted_to("[racecar.jpg]", html) + assert_converted_to("\\[racecar.jpg\\]", html) end test "Blob image with attachment_links: true uses filename" do @@ -780,7 +818,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase blob = create_file_blob(filename: "report.txt", content_type: "text/plain") html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="Captioned"></action-text-attachment>) - assert_converted_to("[Captioned]", html) + assert_converted_to("\\[Captioned\\]", html) end test "Blob with attachment_links: true uses caption" do @@ -797,7 +835,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase blob = create_file_blob(filename: "report.txt", content_type: "text/plain") html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}"></action-text-attachment>) - assert_converted_to("[report.txt]", html) + assert_converted_to("\\[report.txt\\]", html) end test "Blob with attachment_links: true uses filename" do @@ -814,7 +852,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="photo *large*"></action-text-attachment>) - assert_converted_to("[photo \\*large\\*]", html) + assert_converted_to("\\[photo \\*large\\*\\]", html) end test "Blob image with attachment_links: true escapes metacharacters in caption" do @@ -832,8 +870,8 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="photo <large>"></action-text-attachment>) html2 = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="photo &lt;large&gt;"></action-text-attachment>) - assert_converted_to("[photo \\<large\\>]", html) - assert_converted_to("[photo \\<large\\>]", html2) + assert_converted_to("\\[photo \\<large\\>\\]", html) + assert_converted_to("\\[photo \\<large\\>\\]", html2) end test "Blob image with attachment_links: true escapes angle brackets in caption" do @@ -876,7 +914,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase assert_raises(ArgumentError) do ActionText::Content.new(html).to_markdown(attachment_links: true) end - assert_converted_to("[Captioned]", html) + assert_converted_to("\\[Captioned\\]", html) end ensure ActionMailer::Base.default_url_options = original_default_url_options @@ -890,7 +928,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase ActionText::Content.new(html).to_markdown(attachment_links: true) end assert_match(/rendering context/, error.message) - assert_converted_to("[Captioned]", html) + assert_converted_to("\\[Captioned\\]", html) end test "Blob image with renderer uses bracketed title by default" do @@ -898,7 +936,7 @@ class ActionText::MarkdownConversionTest < ActiveSupport::TestCase html = %Q(<action-text-attachment sgid="#{blob.attachable_sgid}" caption="Captioned"></action-text-attachment>) with_controller_renderer do - assert_converted_to("[Captioned]", html) + assert_converted_to("\\[Captioned\\]", html) end end
← Back to Alerts View on GitHub →