Rails 6 の Action Text がどんな感じで XSS の対策をしているか見てみる

Railsで`contenteditable`を使う機会があったので、Action Textはどんな感じでXSS対策をしているか見てみる。
Railsのバージョンは`6.0.0.rc1`。

とりあえずRailsガイドを見ながらAction Textを動かせるようにする。
[Action Text Overview — Ruby on Rails Guides](https://edgeguides.rubyonrails.org/action_text_overview.html)

```bash
mkdir rails-6.0.0rc1
cd rails-6.0.0rc1
bundle init
```

```Gemfile
gem "rails", "6.0.0rc1"
```

```bash
bundle install --path vendor/bundle
bundle exec rails new . -B -d mysql
bundle install --path vendor/bundle
bundle exec rake db:create
bundle exec rails generate scaffold message content:text
bundle exec rake db:migrate
bundle exec rails webpacker:install
bundle exec rails action_text:install
bundle exec rake db:migrate
bundle exec rails s
```

```app/models/message.rb
class Message < ApplicationRecord
  has_rich_text :content
end
```

```erb:app/views/messages/_form.html.erb
<%= form_with(model: message, local: true) do |form| %>
  <% if message.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:</h2>

      <ul>
        <% message.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :content %>
    <%= form.rich_text_area :content %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>
```

ここまで行けばすでに使えるよう状態。

<img width="1276" alt="Screen Shot 2019-06-16 at 7.58.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34733/50042804-6caa-7768-30f8-d4446b332b8e.png">

---

<img width="866" alt="Screen Shot 2019-06-16 at 8.01.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34733/79f2254b-8eb1-5976-c878-a45fdfe1413f.png">

---

<img width="260" alt="Screen Shot 2019-06-16 at 8.48.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34733/fdf7545e-c4ea-ec50-5adf-9f7f9d8c75a6.png">



とりあえず入力してどんな感じでDBに保存されるか見てみる。

```sql

use rails_6_0_0rc1_development;

Database changed
mysql> select * from messages;
+----+---------+----------------------------+----------------------------+
| id | content | created_at                 | updated_at                 |
+----+---------+----------------------------+----------------------------+
|  1 | NULL    | 2019-06-15 23:01:50.325551 | 2019-06-15 23:01:50.360847 |
+----+---------+----------------------------+----------------------------+
1 row in set (0.00 sec)
```

erbが下記のように書いてあったので`messages`テーブルにデータがあるのかと思ったが違った。

```erb:app/views/messages/show.html.erb
<p id="notice"><%= notice %></p>

<p>
  <strong>Content:</strong>
  <%= @message.content %>
</p>

<%= link_to 'Edit', edit_message_path(@message) %> |
<%= link_to 'Back', messages_path %>

```

`action_text_rich_texts`テーブルに保存されるみたい。

```sql
mysql> show tables;
+--------------------------------------+
| Tables_in_rails_6_0_0rc1_development |
+--------------------------------------+
| action_text_rich_texts               |
| active_storage_attachments           |
| active_storage_blobs                 |
| ar_internal_metadata                 |
| messages                             |
| schema_migrations                    |
+--------------------------------------+
6 rows in set (0.00 sec)

mysql> select * from action_text_rich_texts;
+----+---------+----------------------+-------------+-----------+----------------------------+----------------------------+
| id | name    | body                 | record_type | record_id | created_at                 | updated_at                 |
+----+---------+----------------------+-------------+-----------+----------------------------+----------------------------+
|  1 | content | <div>あああ</div>    | Message     |         1 | 2019-06-15 23:01:50.358820 | 2019-06-15 23:01:50.358820 |
+----+---------+----------------------+-------------+-----------+----------------------------+----------------------------+
1 row in set (0.00 sec)
```

どんな仕組みになっているかモデルの中に書いた`has_rich_text :content`を見てみる。


```vendor/bundle/ruby/2.6.0/gems/actiontext-6.0.0.rc1/lib/action_text/attribute.rb
def has_rich_text(name)
    class_eval <<-CODE, __FILE__, __LINE__ + 1
      def #{name}
        rich_text_#{name} || build_rich_text_#{name}
      end

      def #{name}=(body)
        self.#{name}.body = body
      end
    CODE

    has_one :"rich_text_#{name}", -> { where(name: name) },
      class_name: "ActionText::RichText", as: :record, inverse_of: :record, autosave: true, dependent: :destroy

    scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
    scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
  end
end
```

ここで`action_text_rich_texts`テーブルとリレーションを作り、`content`呼び出しも保存も`ActioText::RichText`モデルでやってるっぽい。

```vendor/bundle/ruby/2.6.0/gems/actiontext-6.0.0.rc1/app/models/action_text/rich_text.rb
# frozen_string_literal: true

module ActionText
  # The RichText record holds the content produced by the Trix editor in a serialized +body+ attribute.
  # It also holds all the references to the embedded files, which are stored using Active Storage.
  # This record is then associated with the Active Record model the application desires to have
  # rich text content using the +has_rich_text+ class method.
  class RichText < ActiveRecord::Base
    self.table_name = "action_text_rich_texts"

    serialize :body, ActionText::Content
    delegate :to_s, :nil?, to: :body

    belongs_to :record, polymorphic: true, touch: true
    has_many_attached :embeds

    before_save do
      self.embeds = body.attachables.grep(ActiveStorage::Blob) if body.present?
    end

    def to_plain_text
      body&.to_plain_text.to_s
    end

    delegate :blank?, :empty?, :present?, to: :to_plain_text
  end
end

ActiveSupport.run_load_hooks :action_text_rich_text, ActionText::RichText

```

また、bodyについては`ActionText::Content`のインスタンスになってる。

```bash
bundle exec rails c
irb(main):007:0> Message.first.content.body
=> #<ActionText::Content "<div class=\"trix-conte...">
```

とりあえず`<script>`タグとか入れるとどうなるか試してみる。

```html
<div>
  aaa<br>aaaaabbb
  <img src=javascript:alert('Hello')>
  <table background="javascript:alert('Hello')"></table>
  <script>alert("test");</script>
  <p onmouseover=alert(document.cookie)></p>
  <a href="javascript:alert(document.cookie)"></a>
  <a href="https://www.yahoo.co.jp/">yahoo</a>
</div>
```

適当にHTMLを書いて`HTTPie`で`patch`してみる。

```bash
http --print 'hB' \
--form PATCH "http://localhost:3000/messages/1" \
_method=patch \
authenticity_token=hogehogehogehogheohgoehgoheoghoehgoehogheogheohgoe== \
message[content]='<div>aaa<br>aaaaabbb<img src=javascript:alert('Hello')><table background="javascript:alert('Hello')"></table><script>alert("test");</script><p onmouseover=alert(document.cookie)></p><a href="javascript:alert(document.cookie)"></a><a href="https://www.yahoo.co.jp/">yahoo</a></div>' \
commit='Update Message' \
Cookie:'_rails600rc1_session=hogehogheohgoehogheohgoehgoehogheohgoehgoheoghoehgoehgoehogheoghohe'

```


データベースにはまんま保存されてる。

```sql
select * from action_text_rich_texts;

+----+---------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------+-----------+----------------------------+----------
| id | name    | body                                                                                                                                                                                                                                                                                     | record_type | record_id | created_at                 | updated_a
+----+---------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------+-----------+----------------------------+----------
|  1 | content | <div>aaa<br>aaaaabbb<img src="javascript:alert(Hello)"><table background="javascript:alert(Hello)"></table><script>alert("test");</script><p onmouseover="alert(document.cookie)"></p><a href="javascript:alert(document.cookie)"></a><a href="https://www.yahoo.co.jp/">yahoo</a></div> | Message     |         1 | 2019-06-15 23:01:50.358820 | 2019-06-1
+----+---------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------+-----------+----------------------------+----------
(END)
```

ページ見るといい感じに対策されてるん。
~~※だけど400でQiitaに画像アップできない。どうなってんの。~~できた。
<img width="652" alt="Screen Shot 2019-06-16 at 9.10.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34733/ccaa8495-0620-2df7-8536-a35c8eb9835a.png">


どこでどんなことをやっているのかコード読んでく。
とりあえず`ActionView`に処理が渡ったところから。
コントローラからこの辺で`ActionView`に処理がわたるっぽい。

```vendor/bundle/ruby/2.6.0/gems/actionpack-6.0.0.rc1/lib/abstract_controller/rendering.rb
# Normalizes arguments, options and then delegates render_to_body and
# sticks the result in <tt>self.response_body</tt>.
def render(*args, &block)
  options = _normalize_render(*args, &block)
  rendered_body = render_to_body(options)
  if options[:html]
    _set_html_content_type
  else
    _set_rendered_content_type rendered_format
  end
  self.response_body = rendered_body
end
```

`render_to_body`が呼ばれる。

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/rendering.rb
def render_to_body(options = {})
  _process_options(options)
  _render_template(options)
end
```

`_render_template`内で`in_rendering_context`が呼ばれる。


```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/base.rb
def in_rendering_context(options)
    old_view_renderer  = @view_renderer
    old_lookup_context = @lookup_context

    if !lookup_context.html_fallback_for_js && options[:formats]
      formats = Array(options[:formats])
      if formats == [:js]
        formats << :html
      end
      @lookup_context = lookup_context.with_prepended_formats(formats)
      @view_renderer = ActionView::Renderer.new @lookup_context
    end

    yield @view_renderer
  ensure
    @view_renderer = old_view_renderer
    @lookup_context = old_lookup_context
  end

  ActiveSupport.run_load_hooks(:action_view, self)
end
```

`yield @view_renderer`で処理が`_render_template`に戻り、

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/rendering.rb
def _render_template(options)
  variant = options.delete(:variant)
  assigns = options.delete(:assigns)
  context = view_context

  context.assign assigns if assigns
  lookup_context.variants = variant if variant

  rendered_template = context.in_rendering_context(options) do |renderer|
    renderer.render_to_object(context, options)
  end

  rendered_format = rendered_template.format || lookup_context.formats.first
  @rendered_format = Template::Types[rendered_format]

  rendered_template.body
end
```

`render_to_object`が呼ばれる。

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/renderer/renderer.rb
# Main render entry point shared by Action View and Action Controller.
def render(context, options)
  render_to_object(context, options).body
end

def render_to_object(context, options) # :nodoc:
  if options.key?(:partial)
    render_partial_to_object(context, options)
  else
    render_template_to_object(context, options)
  end
end

def render_template_to_object(context, options) #:nodoc:
  TemplateRenderer.new(@lookup_context).render(context, options)
end
```

今回は`template`なので、`TemplateRenderer`インスタンスの`render`が呼ばれる。`render_template`内の`template`は`ActionView::Template`のインスタンスっぽい。

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/renderer/template_renderer.rb
def render(context, options)
  @details = extract_details(options)
  template = determine_template(options)

  prepend_formats(template.format)

  render_template(context, template, options[:layout], options[:locals] || {})
end

def render_template(view, template, layout_name, locals)
  render_with_layout(view, template, layout_name, locals) do |layout|
    instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do
      template.render(view, locals) { |*name| view._layout_for(*name) }
    end
  end
end
```

`ActionView::Template#render`メソッドの処理を追うのが大変だった。`compile!`メソッド内でメソッドを定義して、`ActionView::Base#_run`で定義したメソッドを呼び出しているっぽい。

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/template.rb
def render(view, locals, buffer = ActionView::OutputBuffer.new, &block)
  instrument_render_template do
    compile!(view)
    view._run(method_name, self, locals, buffer, &block)
  end
rescue => e
  handle_render_error(view, e)
end


def compile(mod)
  source = encode!
  code = @handler.call(self, source)

  # Make sure that the resulting String to be eval'd is in the
  # encoding of the code
  original_source = source
  source = +<<-end_src
    def #{method_name}(local_assigns, output_buffer)
      @virtual_path = #{@virtual_path.inspect};#{locals_code};#{code}
    end
  end_src

  # Make sure the source is in the encoding of the returned code
  source.force_encoding(code.encoding)

  # In case we get back a String from a handler that is not in
  # BINARY or the default_internal, encode it to the default_internal
  source.encode!

  # Now, validate that the source we got back from the template
  # handler is valid in the default_internal. This is for handlers
  # that handle encoding but screw up
  unless source.valid_encoding?
    raise WrongEncodingError.new(source, Encoding.default_internal)
  end

  begin
    mod.module_eval(source, identifier, 0)
  rescue SyntaxError
    # Account for when code in the template is not syntactically valid; e.g. if we're using
    # ERB and the user writes <%= foo( %>, attempting to call a helper `foo` and interpolate
    # the result into the template, but missing an end parenthesis.
    raise SyntaxErrorInTemplate.new(self, original_source)
  end
end
```

今回のケースで言えば、`mod.module_eval(source, identifier, 0)`で定義しているメソッドはこんな感じになる。

```ruby
def _app_views_messages_show_html_erb__2945402757084275790_70123438800260(local_assigns, output_buffer)
  @virtual_path = "messages/show";;
  @output_buffer.safe_append='<p id=\"notice\">'.freeze;
  @output_buffer.append=( notice );
  @output_buffer.safe_append='</p>\n\n<p>\n  <strong>Content:</strong>\n  '.freeze;
  @output_buffer.append=( @message.content );
  @output_buffer.safe_append='\n</p>\n\n'.freeze;
  @output_buffer.append=( link_to 'Edit', edit_message_path(@message) );
  @output_buffer.safe_append=' |\n'.freeze;
  @output_buffer.append=( link_to 'Back', messages_path );\n
  @output_buffer.safe_append='\n'.freeze;
  @output_buffer.to_s
end

```

`@output_buffer.append=( @message.content );`これが何をやっているか追っていけばわかりそう。
少し戻って、`view._run(method_name, self, locals, buffer, &block)`では、

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/base.rb
def _run(method, template, locals, buffer, &block)
  _old_output_buffer, _old_virtual_path, _old_template = @output_buffer, @virtual_path, @current_template
  @current_template = template
  @output_buffer = buffer
  send(method, locals, buffer, &block)
ensure
  @output_buffer, @virtual_path, @current_template = _old_output_buffer, _old_virtual_path, _old_template
end
```

が実行され、`send(method, locals, buffer, &block)`で、`_app_views_messages_show_html_erb__2945402757084275790_70123438800260(local_assigns, output_buffer)`メソッドが呼ばれる。
`@output_buffer`は`OutputBuffer`のインスタンスらしく、`append`はこんな感じになってる。

```vendor/bundle/ruby/2.6.0/gems/actionview-6.0.0.rc1/lib/action_view/buffers.rb
def <<(value)
  return self if value.nil?
  super(value.to_s)
end
alias :append= :<<
```

`value`は`@message.content`なので、`@message.content.to_s`を見てみる。`@message.content`は`ActionText::RichText`のインスタンスになる。
ここでようやくAction Textが出てくる。

```vendor/bundle/ruby/2.6.0/gems/actiontext-6.0.0.rc1/lib/action_text/content.rb
def to_rendered_html_with_layout
  renderer.render(partial: "action_text/content/layout", locals: { content: self })
end

def to_s
  to_rendered_html_with_layout
end
```

`to_s`でパーシャルをレンダーしてる。パーシャルはこんな感じ。

```erb:vendor/bundle/ruby/2.6.0/gems/actiontext-6.0.0.rc1/app/views/action_text/content/_layout.html.erb
<div class="trix-content">
  <%= render_action_text_content(content) %>
</div>
```

ヘルパーはこれ。

```vendor/bundle/ruby/2.6.0/gems/actiontext-6.0.0.rc1/app/helpers/action_text/content_helper.rb
require "rails-html-sanitizer"

module ActionText
  module ContentHelper
    mattr_accessor(:sanitizer) { Rails::Html::Sanitizer.white_list_sanitizer.new }
    mattr_accessor(:allowed_tags) { sanitizer.class.allowed_tags + [ ActionText::Attachment::TAG_NAME, "figure", "figcaption" ] }
    mattr_accessor(:allowed_attributes) { sanitizer.class.allowed_attributes + ActionText::Attachment::ATTRIBUTES }
    mattr_accessor(:scrubber)

    def render_action_text_content(content)
      sanitize_action_text_content(render_action_text_attachments(content))
    end

    def sanitize_action_text_content(content)
      sanitizer.sanitize(content.to_html, tags: allowed_tags, attributes: allowed_attributes, scrubber: scrubber).html_safe
    end

    def render_action_text_attachments(content)
      content.render_attachments do |attachment|
        unless attachment.in?(content.gallery_attachments)
          attachment.node.tap do |node|
            node.inner_html = render(attachment, in_gallery: false).chomp
          end
        end
      end.render_attachment_galleries do |attachment_gallery|
        render(layout: attachment_gallery, object: attachment_gallery) do
          attachment_gallery.attachments.map do |attachment|
            attachment.node.inner_html = render(attachment, in_gallery: true).chomp
            attachment.to_html
          end.join("").html_safe
        end.chomp
      end
    end
  end
end
```

`sanitize_action_text_content(content)`メソッドでXSS対策してますね。中身はこんな感じ。

```vendor/bundle/ruby/2.6.0/gems/rails-html-sanitizer-1.0.4/lib/rails/html/sanitizer.rb
def sanitize(html, options = {})
  return unless html
  return html if html.empty?

  loofah_fragment = Loofah.fragment(html)

  if scrubber = options[:scrubber]
    # No duck typing, Loofah ensures subclass of Loofah::Scrubber
    loofah_fragment.scrub!(scrubber)
  elsif allowed_tags(options) || allowed_attributes(options)
    @permit_scrubber.tags = allowed_tags(options)
    @permit_scrubber.attributes = allowed_attributes(options)
    loofah_fragment.scrub!(@permit_scrubber)
  else
    remove_xpaths(loofah_fragment, XPATHS_TO_REMOVE)
    loofah_fragment.scrub!(:strip)
  end

  properly_encode(loofah_fragment, encoding: 'UTF-8')
end
```

リリースが楽しみ。