Railsのsanitizeを深追いしてみる

# はじめに

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

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

# 調査

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

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

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


https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/actionview/lib/action_view/helpers/sanitize_helper.rb#L81


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

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

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/actionview/lib/action_view/helpers/sanitize_helper.rb#L165-L167

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

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

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/actionview/lib/action_view/helpers/sanitize_helper.rb#L127-L129

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

https://github.com/rails/rails-html-sanitizer

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

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

https://github.com/rails/rails-html-sanitizer/blob/1e64885d30668165c63cccc4e371d1fc2755324a/lib/rails-html-sanitizer.rb#L18-L20


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

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

```ruby: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
```

https://github.com/rails/rails-html-sanitizer/blob/79bc10bc0cb07624c43a66899f16385b7dab4f76/lib/rails/html/sanitizer.rb#L117-L136


`sanitize`メソッドに渡せるオプション（tags、attributes、scrubber）による分岐はここで行なっているようです。

おぉこれでオプションと処理のイメージが結びつきました！

どの分岐を通っても最終的には`loofah_fragment.scrub!`に行き着くようです。

それにしてもこの`Loofah`とは一体なんでしょう。
気になるのでさらに調べてみました。GitHubリポジトリはこちら。

https://github.com/flavorjones/loofah

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

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

https://github.com/flavorjones/loofah/blob/cc5ce1c6ade918a03730c83cb870e968b7598120/lib/loofah.rb#L41-L43

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


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

```ruby: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
```

https://github.com/flavorjones/loofah/blob/cc5ce1c6ade918a03730c83cb870e968b7598120/lib/loofah/html/document_fragment.rb#L18-L25

これで`Loofah.fragment`の戻り値はわかったので、次は`scrub!`メソッドの定義を探してみます。

と思ったが見つからない！？

`Loofah::ScrubBehavior::Node`モジュールと`Loofah::ScrubBehavior::NodeSet`モジュールに`scrub!`メソッドは定義されているのですが、`Loofah::HTML::DocumentFragment`ではそのモジュールを読み込んでいません。

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

```ruby: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
```

https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/instance_methods.rb#L126-L132

どうやらこの`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`が呼ばれていくようです。

```ruby: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
```


https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/instance_methods.rb#L32


scrubberは`Scrubbers::MAP`から取得しています。名前からしてマッピング情報を持っていそうですね。

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

https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/instance_methods.rb#L60-L61

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

```ruby: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,
}
```

https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/scrubbers.rb#L281-L290

`traverse`を辿っていくと`scrub`に辿り着くので、`Strip`クラスの`scrub`メソッド定義を見ます。


`html5lib_sanitize`の結果が`CONTINUE`だったら`CONTINUE`を返し、それ以外は`node.children`を`node`の前に追加した後に`node`自身を削除し、`STOP`を返しています。

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

https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/scrubbers.rb#L101-L106

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

```ruby: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
```

https://github.com/flavorjones/loofah/blob/69447605c6671448965bd05da3784956c64024e5/lib/loofah/scrubber.rb#L103-L118

`allowed_element?`の判定は最終的に`Loofah::HTML5::SafeList::ALLOWED_ELEMENTS_WITH_LIBXML2`に含まれるかどうかになります。
許容しているタグだったら`HTML5::Scrub.scrub_attributes`でさらに要素を削除していく流れです。

このように順番にHTMLを解析して不正なタグや属性を除去していくんですね！

少し話を戻して`sanitize`メソッドのオプションでtagsやattributesを指定した場合ですが、`PermitScrubber`という`Loofah::Scrubber`を継承したクラスがありそのクラスが`scrub`を行うようです。

https://github.com/rails/rails-html-sanitizer/blob/79bc10bc0cb07624c43a66899f16385b7dab4f76/lib/rails/html/scrubbers.rb#L47

# おわりに

軽い気持ちで調べ始めたらLoofahやNokogiriなど思ったよりも奥深くまで調べるハメになってしまいました。

しかし、言語やフレームワークが提供しているメソッドは普段使用していても実装がどうなっているかは知らないものがほとんどなので、今回の調査は良い経験になりました。特にメソッドに渡すオプションと処理のイメージが結びついたことが大きな成果でした。

どうやらLoofah以外にもサニタイズに関するgemはあるようなので、気になる方は調べてみると良いかもしれません。