LoginSignup
10
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-06-16

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

とりあえずRailsガイドを見ながらAction Textを動かせるようにする。
Action Text Overview — Ruby on Rails Guides

mkdir rails-6.0.0rc1
cd rails-6.0.0rc1
bundle init
gem "rails", "6.0.0rc1"
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
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 %>

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

Screen Shot 2019-06-16 at 7.58.46.png


Screen Shot 2019-06-16 at 8.01.46.png


Screen Shot 2019-06-16 at 8.48.29.png

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


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テーブルにデータがあるのかと思ったが違った。

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テーブルに保存されるみたい。

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のインスタンスになってる。

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

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

<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を書いてHTTPiepatchしてみる。

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'

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

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に画像アップできない。どうなってんの。できた。
Screen Shot 2019-06-16 at 9.10.36.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内のtemplateActionView::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)で定義しているメソッドはこんな感じになる。

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_bufferOutputBufferのインスタンスらしく、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.contentActionText::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でパーシャルをレンダーしてる。パーシャルはこんな感じ。

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

リリースが楽しみ。

10
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6