5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Railsのsanitizeを深追いしてみる

Posted at

はじめに

RailsにはsanitizeというHTML中のscriptタグなどを除去してくれるメソッドが存在しており、動的にHTMLを出力する際にXSS対策に有効な手段として広く使われています。

どのようなタグを除去してくれるか等、sanitizeメソッドの実装が気になり調べてみたので備忘録として残しておきます。

調査

はじめにRailsコンソールでsanitizeメソッドの定義元を調べます。

rails console
> 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メソッドの定義は以下のようになっています。

rails/actionview/lib/action_view/helpers/sanitize_helper.rb
def sanitize(html, options = {})
  self.class.safe_list_sanitizer.sanitize(html, options)&.html_safe
end

どうやらsafe_list_sanitizersanitizeメソッドを呼んでいるようです。
次にsafe_list_sanitizerの定義元を調べてみます。

rails/actionview/lib/action_view/helpers/sanitize_helper.rb
def safe_list_sanitizer
  @safe_list_sanitizer ||= sanitizer_vendor.safe_list_sanitizer.new
end

今度はsanitizer_vendorというものが登場しました。
sanitizer_vendorの定義を見るとRails::Html::Sanitizerとなっています。
これが標準のサニタイズとして使われているようです。

rails/actionview/lib/action_view/helpers/sanitize_helper.rb
def sanitizer_vendor
  Rails::Html::Sanitizer
end

次にRails::Html::Sanitizerのソースコードを調べてみます。
GitHubのリポジトリはこちら。

先ほどのsafe_list_sanitizerの定義を探します。Html::SafeListSanitizerクラスを返していることがわかりました。

rails-html-sanitizer/lib/rails-html-sanitizer.rb
def safe_list_sanitizer
  Html::SafeListSanitizer
end

そしてHtml::SafeListSanitizerクラスの実装を見ると、ついにsanitizeメソッドの定義を発見しました!

ここまで来ればもう安心!と思ったのも束の間。Loofahという謎のクラスが出現します。

rails-html-sanitizer/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

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を呼び出していることがわかりました。

loofah/lib/loofah.rb
def fragment(*args, &block)
  Loofah::HTML::DocumentFragment.parse(*args, &block)
end

parseメソッドの定義はこちら。Loofah::HTML::DocumentFragmentのインスタンスを返しています。Loofah::HTML::DocumentFragmentNokogiri::HTML::DocumentFragmentを継承したクラスのようです。

ほうほう、Descriptionに記載の通りNokogiriをベースにしていることが伝わってきました。

loofah/lib/loofah/html/document_fragment.rb
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ではそのモジュールを読み込んでいません。

調べているとなにやら怪しいモジュールを発見。

loofah/lib/loofah/instance_methods.rb
module DocumentDecorator # :nodoc:
  def initialize(*args, &block)
    super
    self.decorators(Nokogiri::XML::Node) << ScrubBehavior::Node
    self.decorators(Nokogiri::XML::NodeSet) << ScrubBehavior::NodeSet
  end
end

どうやらこのDocumentDecoratorincludeしたLoofah::HTML::Documentのインスタンスを生成する際にdecoratorsがセットされ、その後Nokogiri内の処理でMix-inされることによってscrub!メソッドが使えるようになるみたいです。

GitHubで編集履歴を辿ると昔はNokogiri::HTML::DocumentFragment内で直接モジュールをincludeしていたようですが、Nokogiriの実装に合わせたのかこのような方式に変わったようです。

Nokogiriの話は以上にしてscrub!メソッドの実装を見ていきましょう。
自身のクラスによって処理を分岐しています。Nokogiri::XML::DocumentFragmentの場合はchildren.scrub!を呼んで再帰処理になるので最終的にはscrubber.traverseが呼ばれていくようです。

loofah/lib/loofah/instance_methods.rb
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から取得しています。名前からしてマッピング情報を持っていそうですね。

loofah/lib/loofah/instance_methods.rb
def ScrubBehavior.resolve_scrubber(scrubber) # :nodoc:
    scrubber = Scrubbers::MAP[scrubber].new if Scrubbers::MAP[scrubber]

Scrubbers::MAPの定義を見てみます。色々なクラスがあるようですね。今回は:stripに絞って調査しようと思います。

loofah/lib/loofah/instance_methods.rb
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.childrennodeの前に追加した後にnode自身を削除し、STOPを返しています。

loofah/lib/loofah/scrubbers.rb
def scrub(node)
  return CONTINUE if html5lib_sanitize(node) == CONTINUE
  node.before(node.children)
  node.remove
  return STOP
end

html5lib_sanitizeメソッドの定義を見てみます。
nodeの種類によって処理を分岐していますね。

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

5
2
0

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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?