Action TextのMarkdown変換における安全性とエッジケースの改善
Action Textの to_markdown 機能において、コードブロック、リンク、URI検証の各処理で発生していたエッジケースと安全性の問題が修正されました。これにより、Markdown出力の信頼性が向上し、より正確な変換結果が得られるようになります。
背景
#56858 でAction TextにMarkdown変換機能が追加された際、いくつかの実装上の課題が残されていました。コードブロック内の空白文字が失われる問題、リンクURLに特殊文字が含まれる場合の構文エラー、そしてURIプロトコルチェックを迂回できる脆弱性です。
これらの問題は、リッチテキストをMarkdown形式で利用する様々な場面で、予期しない出力やセキュリティリスクをもたらす可能性がありました。本PRはこれらのエッジケースを体系的に解決しています。
コードブロックの空白文字保持
<pre> 要素内の空白文字とインデントが正確に保持されるようになりました。従来の strip メソッドによる処理では、ブロックの先頭・末尾の改行だけでなく、意味のあるインデントまで削除されていました。
変更前:
def visit_pre(node, child_values)
inner = child_values.join.strip
"```\n#{inner}\n```"
end
変更後:
def visit_pre(node, child_values)
inner = child_values.join.delete_prefix("\n").delete_suffix("\n")
fence_length = [3, inner.scan(/```+/).map(&:length).max.to_i + 1].max
fence = "`" * fence_length
"#{fence}\n#{inner}\n#{fence}"
end
delete_prefix / delete_suffix により、コードブロックの境界にある改行のみを削除し、内部の空白行やインデントは保持されます。また、コンテンツ内に3連バッククォート()が含まれる場合は、より長いフェンス(`)を使用することで、正しいMarkdown構文を生成します。
テストケースでは、空白行の保持と行頭・行末スペースの維持が検証されています:
test "<pre> preserves inner blank lines" do
assert_converted_to(
"```\n\nblank first line\n```",
"<pre><code>\n\nblank first line\n</code></pre>"
)
end
test "<pre> preserves leading and trailing spaces on lines" do
assert_converted_to(
"```\n indented\nline \n```",
"<pre><code> indented\nline </code></pre>"
)
end
インラインコードのバッククォート処理
インラインの <code> 要素内にバッククォートが含まれる場合の処理が改善されました。コンテンツに含まれるバッククォートの数に応じて、より長いデリミタを動的に選択します。
def visit_code(node, child_values)
inner = child_values.join
if node.parent&.name == "pre"
inner
else
backtick_count = [1, inner.scan(/`+/).map(&:length).max.to_i + 1].max
delimiter = "`" * backtick_count
padding = (inner.start_with?("`") || inner.end_with?("```")) ? " " : ""
"#{delimiter}#{padding}#{inner}#{padding}#{delimiter}"
end
end
foobarのようなコードは foo`bar ` に変換され、先頭または末尾がバッククォートの場合はスペースパディングが追加されます。これにより、Markdownパーサーがコード範囲を正確に認識できます。
リンクのエスケープとURIエンコーディング
Markdownリンク構文の安全性と正確性が向上しました。新設された markdown_link ヘルパーメソッドが、リンクテキストとURLの両方を適切に処理します。
def markdown_link(title, url)
"[#{escape_link_text(title)}](#{encode_href(url)})"
end
private
def escape_link_text(text)
text.to_s.gsub(/[\[\]\\]/) { |char| "\\#{char}" }
end
def encode_href(href)
href.to_s.gsub(ENCODE_HREF_CHARS) { |char| "%#{char.ord.to_s(16).upcase}" }
end
リンクテキスト内の [ ] \ はエスケープされ、Markdownリンクインジェクションが防止されます。URL内の括弧、スペース、山括弧、改行、タブ、キャリッジリターンはパーセントエンコーディングされ、構文的に有効なMarkdownリンクが生成されます。
RemoteImage の添付ファイルにもこの処理が適用されています:
def attachable_markdown_representation(caption)
"!#{MarkdownConversion.markdown_link(caption || "Image", url)}"
end
画像リンクのURLも同様にエンコードされ、photo_(large).png のようなファイル名が正しく処理されます。
URI安全性の強化
URIプロトコルチェックが Rails::HTML::Sanitizer.allowed_uri? に委譲され、より堅牢な検証が行われるようになりました。
変更前:
def safe_uri?(href)
href !~ PROTOCOL_REGEXP || SAFE_PROTOCOLS.include?($1.downcase)
end
変更後:
def safe_uri?(href)
Rails::HTML::Sanitizer.safe_list_sanitizer.allowed_uri?(href)
end
従来の正規表現ベースのチェックでは、先頭の空白文字、タブ、改行、HTMLエンティティエンコーディングによって javascript: プロトコルの検出を迂回できました。Rails::HTML::Sanitizer の実装は、制御文字の除去、HTMLエンティティのデコード、大文字小文字の正規化、data: メディアタイプの検証を行うため、これらの迂回手法が無効化されます。
この変更に伴い、rails-html-sanitizer の依存バージョンが ~> 1.7 に更新されました。これは公開APIとして allowed_uri? が提供されるバージョンです:
- s.add_dependency "rails-html-sanitizer", "~> 1.6"
+ s.add_dependency "rails-html-sanitizer", "~> 1.7"
同様の変更が actionview.gemspec にも適用されています。
設計判断
正規表現チェックから標準ライブラリへの移行という方針が採用されました。URIプロトコル検証を独自実装からRailsの標準サニタイザに委譲することで、セキュリティ専門チームによるメンテナンスの恩恵を受けられます。
コードブロックとインラインコードの処理では、コンテンツ解析による動的なデリミタ選択が実装されました。固定長のフェンスではなく、コンテンツに応じた長さを選択することで、あらゆるコード内容に対応できます。
リンク処理では、URLエンコーディングとテキストエスケープの分離が明確化されました。markdown_link ヘルパーの導入により、リンク生成のロジックが一箇所に集約され、RemoteImage などの他のコンポーネントでも再利用可能になっています。
テストケースの追加により、これらのエッジケースが将来のリファクタリングでも保護されることが保証されます。142行のテスト追加は、実装の信頼性向上への投資といえます。
まとめ
本PRは、Markdown変換機能の実用性と安全性を高める修正です。空白文字の保持、特殊文字を含むリンクの正確な処理、そしてURIプロトコルチェックの強化により、Action Textから生成されるMarkdownがより信頼性の高いものになりました。rails-html-sanitizer への依存により、セキュリティ面でもRailsエコシステム全体の改善の恩恵を受けられる設計となっています。