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 # => ""
#
+ # 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 (++).
+ #
+ # MarkdownConversion.markdown_link("photo", "https://example.com/photo.png", image: true)
+ # # => ""
+ #
+ # 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 "", 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 "", 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(
+ "",
+ '<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(
"",
@@ -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 <large>"></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