はじめに
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はあるようなので、気になる方は調べてみると良いかもしれません。