はじめに
Railsにはsanitize
というHTML中のscriptタグなどを除去してくれるメソッドが存在しており、動的にHTMLを出力する際にXSS対策に有効な手段として広く使われています。
どのようなタグを除去してくれるか等、sanitize
メソッドの実装が気になり調べてみたので備忘録として残しておきます。
調査
はじめにRailsコンソールでsanitize
メソッドの定義元を調べます。
> helper.method(:sanitize).source_location
=> ["/Users/massaaaaan/.rbenv/versions/3.2.0/lib/ruby/gems/3.2.0/gems/actionview-6.1.7.1/lib/action_view/helpers/sanitize_helper.rb", 81]
これでsanitize
メソッドはActionView::Helpers::SanitizeHelper
というモジュールで定義されていることがわかりました。
ここからはGitHubをベースに調査していきます。sanitize
メソッドの定義は以下のようになっています。
def sanitize(html, options = {})
self.class.safe_list_sanitizer.sanitize(html, options)&.html_safe
end
どうやらsafe_list_sanitizer
のsanitize
メソッドを呼んでいるようです。
次にsafe_list_sanitizer
の定義元を調べてみます。
def safe_list_sanitizer
@safe_list_sanitizer ||= sanitizer_vendor.safe_list_sanitizer.new
end
今度はsanitizer_vendor
というものが登場しました。
sanitizer_vendor
の定義を見るとRails::Html::Sanitizer
となっています。
これが標準のサニタイズとして使われているようです。
def sanitizer_vendor
Rails::Html::Sanitizer
end
次にRails::Html::Sanitizer
のソースコードを調べてみます。
GitHubのリポジトリはこちら。
先ほどのsafe_list_sanitizer
の定義を探します。Html::SafeListSanitizer
クラスを返していることがわかりました。
def safe_list_sanitizer
Html::SafeListSanitizer
end
そしてHtml::SafeListSanitizer
クラスの実装を見ると、ついにsanitize
メソッドの定義を発見しました!
ここまで来ればもう安心!と思ったのも束の間。Loofah
という謎のクラスが出現します。
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
sanitize
メソッドに渡せるオプション(tags、attributes、scrubber)による分岐はここで行なっているようです。
おぉこれでオプションと処理のイメージが結びつきました!
どの分岐を通っても最終的にはloofah_fragment.scrub!
に行き着くようです。
それにしてもこのLoofah
とは一体なんでしょう。
気になるのでさらに調べてみました。GitHubリポジトリはこちら。
ちなみにLoofahとは日本語でヘチマという意味らしいです。
Descriptionを読んでみるとやはりこのLoofahがサニタイズを行なっている中核のようです。スクレイピングなどによく使われるNokogiriというgemをベースにしているそうです。
Loofah is a general library for manipulating and transforming HTML/XML documents and fragments, built on top of Nokogiri.
Loofah excels at HTML sanitization (XSS prevention). It includes some nice HTML sanitizers, which are based on HTML5lib's safelist, so it most likely won't make your codes less secure. (These statements have not been evaluated by Netexperts.)
ActiveRecord extensions for sanitization are available in the loofah-activerecord gem.
先ほどのsanitize
メソッドで呼んでいたLoofah.fragment
の戻り値を知りたいのでfragment
の定義を探してみます。
するとLoofah::HTML::DocumentFragment.parse
を呼び出していることがわかりました。
def fragment(*args, &block)
Loofah::HTML::DocumentFragment.parse(*args, &block)
end
parse
メソッドの定義はこちら。Loofah::HTML::DocumentFragment
のインスタンスを返しています。Loofah::HTML::DocumentFragment
はNokogiri::HTML::DocumentFragment
を継承したクラスのようです。
ほうほう、Descriptionに記載の通りNokogiriをベースにしていることが伝わってきました。
def parse(tags, encoding = nil)
doc = Loofah::HTML::Document.new
encoding ||= tags.respond_to?(:encoding) ? tags.encoding.name : "UTF-8"
doc.encoding = encoding
new(doc, tags)
end
これでLoofah.fragment
の戻り値はわかったので、次はscrub!
メソッドの定義を探してみます。
と思ったが見つからない!?
Loofah::ScrubBehavior::Node
モジュールとLoofah::ScrubBehavior::NodeSet
モジュールにscrub!
メソッドは定義されているのですが、Loofah::HTML::DocumentFragment
ではそのモジュールを読み込んでいません。
調べているとなにやら怪しいモジュールを発見。
module DocumentDecorator # :nodoc:
def initialize(*args, &block)
super
self.decorators(Nokogiri::XML::Node) << ScrubBehavior::Node
self.decorators(Nokogiri::XML::NodeSet) << ScrubBehavior::NodeSet
end
end
どうやらこのDocumentDecorator
をinclude
したLoofah::HTML::Document
のインスタンスを生成する際にdecorators
がセットされ、その後Nokogiri内の処理でMix-inされることによってscrub!
メソッドが使えるようになるみたいです。
GitHubで編集履歴を辿ると昔はNokogiri::HTML::DocumentFragment
内で直接モジュールをinclude
していたようですが、Nokogiriの実装に合わせたのかこのような方式に変わったようです。
Nokogiriの話は以上にしてscrub!
メソッドの実装を見ていきましょう。
自身のクラスによって処理を分岐しています。Nokogiri::XML::DocumentFragment
の場合はchildren.scrub!
を呼んで再帰処理になるので最終的にはscrubber.traverse
が呼ばれていくようです。
def scrub!(scrubber)
scrubber = ScrubBehavior.resolve_scrubber(scrubber)
case self
when Nokogiri::XML::Document
scrubber.traverse(root) if root
when Nokogiri::XML::DocumentFragment
children.scrub! scrubber
else
scrubber.traverse(self)
end
self
end
scrubberはScrubbers::MAP
から取得しています。名前からしてマッピング情報を持っていそうですね。
def ScrubBehavior.resolve_scrubber(scrubber) # :nodoc:
scrubber = Scrubbers::MAP[scrubber].new if Scrubbers::MAP[scrubber]
Scrubbers::MAP
の定義を見てみます。色々なクラスがあるようですね。今回は:strip
に絞って調査しようと思います。
MAP = {
:escape => Escape,
:prune => Prune,
:whitewash => Whitewash,
:strip => Strip,
:nofollow => NoFollow,
:noopener => NoOpener,
:newline_block_elements => NewlineBlockElements,
:unprintable => Unprintable,
}
traverse
を辿っていくとscrub
に辿り着くので、Strip
クラスのscrub
メソッド定義を見ます。
html5lib_sanitize
の結果がCONTINUE
だったらCONTINUE
を返し、それ以外はnode.children
をnode
の前に追加した後にnode
自身を削除し、STOP
を返しています。
def scrub(node)
return CONTINUE if html5lib_sanitize(node) == CONTINUE
node.before(node.children)
node.remove
return STOP
end
html5lib_sanitize
メソッドの定義を見てみます。
nodeの種類によって処理を分岐していますね。
def html5lib_sanitize(node)
case node.type
when Nokogiri::XML::Node::ELEMENT_NODE
if HTML5::Scrub.allowed_element? node.name
HTML5::Scrub.scrub_attributes node
return Scrubber::CONTINUE
end
when Nokogiri::XML::Node::TEXT_NODE, Nokogiri::XML::Node::CDATA_SECTION_NODE
if HTML5::Scrub.cdata_needs_escaping?(node)
node.before(HTML5::Scrub.cdata_escape(node))
return Scrubber::STOP
end
return Scrubber::CONTINUE
end
Scrubber::STOP
end
allowed_element?
の判定は最終的にLoofah::HTML5::SafeList::ALLOWED_ELEMENTS_WITH_LIBXML2
に含まれるかどうかになります。
許容しているタグだったらHTML5::Scrub.scrub_attributes
でさらに要素を削除していく流れです。
このように順番にHTMLを解析して不正なタグや属性を除去していくんですね!
少し話を戻してsanitize
メソッドのオプションでtagsやattributesを指定した場合ですが、PermitScrubber
というLoofah::Scrubber
を継承したクラスがありそのクラスがscrub
を行うようです。
おわりに
軽い気持ちで調べ始めたらLoofahやNokogiriなど思ったよりも奥深くまで調べるハメになってしまいました。
しかし、言語やフレームワークが提供しているメソッドは普段使用していても実装がどうなっているかは知らないものがほとんどなので、今回の調査は良い経験になりました。特にメソッドに渡すオプションと処理のイメージが結びついたことが大きな成果でした。
どうやらLoofah以外にもサニタイズに関するgemはあるようなので、気になる方は調べてみると良いかもしれません。