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
class Message < ApplicationRecord
has_rich_text :content
end
<%= 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 %>
ここまで行けばすでに使えるよう状態。



とりあえず入力してどんな感じで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
テーブルにデータがあるのかと思ったが違った。
<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
を見てみる。
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
モデルでやってるっぽい。
# 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を書いてHTTPie
でpatch
してみる。
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に画像アップできない。どうなってんの。~~できた。
どこでどんなことをやっているのかコード読んでく。
とりあえずActionView
に処理が渡ったところから。
コントローラからこの辺でActionView
に処理がわたるっぽい。
# 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
が呼ばれる。
def render_to_body(options = {})
_process_options(options)
_render_template(options)
end
_render_template
内でin_rendering_context
が呼ばれる。
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
に戻り、
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
が呼ばれる。
# 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
のインスタンスっぽい。
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
で定義したメソッドを呼び出しているっぽい。
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)
では、
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
はこんな感じになってる。
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が出てくる。
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
でパーシャルをレンダーしてる。パーシャルはこんな感じ。
<div class="trix-content">
<%= render_action_text_content(content) %>
</div>
ヘルパーはこれ。
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対策してますね。中身はこんな感じ。
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
リリースが楽しみ。