Action TextのMarkdown変換における安全性とエッジケースの改善

rails/rails

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エコシステム全体の改善の恩恵を受けられる設計となっています。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の構成が明確です。必須要素はすべて含まれており、理想的な記事構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```言語:ファイルパス)やPR番号のリンク記法([#123](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Action Textの内部実装やMarkdown変換ロジックに関する内容で、専門知識を持つエンジニアという対象読者に完全に適合しています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構成になっており、各段落もトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られています。非常に読みやすいです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロック(変更前・変更後)は、提供されたDiff情報と完全に一致しています。省略や改変は見られません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`delete_prefix`, `パーセントエンコーディング`, `Rails::HTML::Sanitizer` といった技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「stripからdelete_prefix/suffixへの変更による空白保持」「Rails::HTML::Sanitizerへの委譲によるセキュリティ強化」など、すべての技術的説明がPR情報とDiffによって裏付けられており、正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのDescriptionやDiffで裏付けられています。根拠のない推測や憶測(ハルシネーション)は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#56871, #56858)、依存ライブラリのバージョン番号(~> 1.7)、追加されたテストの行数(142行)など、すべての数値・固有名詞が正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「Action TextのMarkdown変換における安全性とエッジケースの改善」は、PRの主題を的確に要約しており、内容との一貫性が保たれています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内の情報はすべてPR情報に基づいており、LTSやEOL、リリース日程といったPRに記載のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「〜が修正されました」「〜ようになりました」といった完了形の表現が用いられており、PRで既に行われた変更であることが正確に伝わります。時間表現の歪曲はありません。