【Rails】gem sanitizeを用いてHTMLをサニタイズしてみた

## はじめに

HTMLの中にあるscriptタグなどの除去(サニタイズ)を行いたかったため、Railsにおけるサニタイズの方法のベンチマークを行い、ベンチマークにおいて最良と判断したgemを使ってサニタイズの処理を実装しました。本記事ではベンチマーク結果とsanitizeというgemを用いたサニタイズの方法についてまとめています。

## 実現したかったこと

実現したかったことは、下記のHTMLに含まれるようなscriptタグやaタグのhref属性値にJavascriptが含まれる場合を想定し、scriptタグを全て除去、aタグがhref属性値がURLなどの安全でない値の場合に除去することでした。

```html:sample.html
<script>console.log('hoge');</script> 👈
<h1>header1</h1>
<h2>header2</h2>
<h3>header3<script>console.log('piyo');</script></h3> 👈

<a href='mailto:test@example.co.jp?subject=件名'>お問い合わせ</a>
<a href='https://www.test.com/?hl=ja'>ホーム画面</a> 
<a href='javascript:""'>aタグに埋め込まれたJavascript</a> 👈

<div>
  <div>
    <div>
      <script>console.log('fuga');</script> 👈
    </div>
  </div>
</div>

<table>
  <tr>
    <td>hoge<script>console.log('hogehoge');</script></td> 👈
    <td>fuga</td>
    <td>piyo</td>
    <script>console.log('fugafuga');</script> 👈
  </tr>
  <tr>
    <td>hogehoge</td>
    <td>fugafuga</td>
    <td>piyopiyo</td>
  </tr>
</table>
```

## ベンチマークと検証

### ActionView::Helpers::SanitizeHelper

まずは、Railsの機能の[SanitizeHelper](https://api.rubyonrails.org/classes/ActionView/Helpers/SanitizeHelper.html)を用いることを試しました。
このhelperは、後述のloofahとrails-html-sanitizerというgemがベースになっているものになります。
カスタムして使いたい時は、`:scrubber`オプションを用います。

検証用に下記のHTMLを準備しました。



```html:sample2.html
<h1>test</h1>
<h2>test2</h2>
<h3>test3<script>console.log('test');</script></h3>
```

下記がLoofahを用いた場合の方法になります。

```ruby:Railsコンソール
> unsafe_html
=> "<h1>test</h1>\n<h2>test2</h2>\n<h3>test3<script>console.log('test');</script></h3>"

> html_fragment = Loofah.fragment(unsafe_html)
=>
#(DocumentFragment:0x35fc {
...

> scrubber = Rails::Html::TargetScrubber.new
=> #<Rails::Html::TargetScrubber:0x00007fb7911180a0 @attributes=nil, @direction=:bottom_up, @tags=nil>

> scrubber.tags = ['script']　# 👈 サニタイズするタグを指定
=> ["script"]

> html_fragment.scrub!(scrubber) # 👈 サニタイズ実行
=>
#(DocumentFragment:0x35fc {
  name = "#document-fragment",
  children = [
    #(Element:0x3610 { name = "h1", children = [ #(Text "test")] }),
    #(Text "\n"),
    #(Element:0x3624 { name = "h2", children = [ #(Text "test2")] }),
    #(Text "\n"),
    #(Element:0x3638 { name = "h3", children = [ #(Text "test3"), #(Text "console.log('test');")] })]
  })

> html_fragment.to_s
=> "<h1>test</h1>\n<h2>test2</h2>\n<h3>test3console.log('test');</h3>" # 👈 sciptタグを削除できた
```

sciptタグはサニタイズできましたが、scriptタグ内にあった文字列は残ってしまいました。
これについては、正規表現で文字列を置換することで対応しました。

```ruby
unsafe_html.gsub(/<script>.*<\/script>/, "<script></script>")
```

一方、scrubber.tagsでタグの指定はできますが、より細かな設定ができないことが課題として残りました。

### サニタイズできるgem
 
他にもサニタイズができるgemがないか探してみたところ、圧倒的スター数を持つsanitizeというgemを見つけました。

| gem名 | スター数 | 最終リリース日 |
| --- | --- | --- |
| [sanitize](https://github.com/rgrove/sanitize) | 1.9K | 2021.1.12 |
| [loofah](https://github.com/flavorjones/loofah) | 804 | 2021.4.7 |
| [rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer) | 223 | 2019.10.7 |
| [htmlfilter](https://github.com/rubyworks/htmlfilter) | 8 | 2012.12 |

sanitizeの開発者が「RubyのHTMLサニタイズライブラリーの偏った比較」というものをまとめていました。
[Biased comparison of Ruby HTML sanitization libraries](https://github.com/rgrove/sanitize/blob/main/COMPARISON.md)

> It's heavily biased because I'm the author of Sanitize, 

開発者は「私はSanitizeの開発者だから非常に偏った内容だよ」と言っていますが、内容を見るとスター数が物語っているように機能面とパフォーマンス面共に他者を圧倒していることが分かります。(内容は割愛します)

設定の自由度も高いため、sanitizeというgemを使う方向で検証を進めました。

## sanitizeの使い方

### 基本的な使い方

基本的な使い方は、[公式のReadme](https://github.com/rgrove/sanitize)と下記の記事を参考にさせていただきました。

[railsでhtmlをsanitize gemでカスタマイズしてsanitizeしたい](https://qiita.com/kogax/items/7bd4f2421e1939d0412e)

### 設定のカスタム方法

デフォルトでサニタイズの強度別に4種類の設定があります。
各設定の設定項目は下記のファイルで参照できます。

https://github.com/rgrove/sanitize/blob/main/lib/sanitize/config/default.rb
https://github.com/rgrove/sanitize/blob/main/lib/sanitize/config/basic.rb
https://github.com/rgrove/sanitize/blob/main/lib/sanitize/config/relaxed.rb
https://github.com/rgrove/sanitize/blob/main/lib/sanitize/config/restricted.rb

カスタムで設定したい場合、initializerを作ってカスタムの設定を定義できます。
`CUSTOM`という設定名で下記のように設定を作ってみました。

```config/initializers/sanitize.rb
class Sanitize
  module Config
    CUSTOM = freeze_config(
      :elements => %w[
        b em i strong u
　　　　　　　　　　　　　　　　~中略~
      ],

      :attributes => {
                           :all         => %w[class dir hidden id lang style tabindex title translate],
                           'abbr'       => %w[title],
                           'blockquote' => %w[cite]
                           # ~中略~
      },

      :protocols => {
                          'a'           => {'href' => ['http', 'https', 'mailto', :relative]}
                          # ~中略~
      },

      :remove_contents => %w[iframe math ~中略~　], # デフォルト設定を明示的に記載

      :css => {

        :at_rules_with_properties => %w[
          bottom-center
          bottom-left
          ~中略~
        ]
      }
    )
  end
end
```

## sanitizeを実際に使ってみた

実際にサニタイズを実行した結果が下記となります。

```ruby
> unsafe_html = "<script>console.log('hoge');</script>
<h1>header1</h1></td>
<h2>header2</h2></td>
<h3>header3<script>console.log('piyo');</script></h3>

<a href='mailto:test@example.co.jp?subject=件名'>お問い合わせ</a>
<a href='https://www.google.com/?hl=ja'>Google</a>
<a href='javascript:""'>aタグに埋め込まれたJS</a>

<div>
  <div>
    <div>
      <script>console.log('fuga');</script>
    </div>
  </div>
</div>

<table>
  <tr>
    <td>hoge<script>console.log('hogehoge');</script></td>
    <td>fuga</td>
    <td>piyo</td>
    <script>console.log('fugafuga');</script>
  </tr>
  <tr>
    <td>hogehoge</td>
    <td>fugafuga</td>
    <td>piyopiyo</td>
  </tr>
</table>"

> html = unsafe_html.gsub(/<script>.*<\/script>/, "<script></script>")
=> "<script></script>\n<h1>header1</h1>\n<h2>header2</h2>\n<h3>header3<script></script></h3>\n\n<a href='mailto:test@example.co.jp?subject=件名'>お問い合わせ</a>\n<a href='https://www.google.com/?hl=ja'>Google</a>\n<a href='javascript:'>aタグに埋め込まれたJS</a>\n\n<div>\n  <div>\n    <div>\n      <script></script>\n    </div>\n  </div>\n</div>\n\n<table>\n  <tr>\n    <td>hoge<script></script></td>\n    <td>fuga</td>\n    <td>piyo</td>\n    <script></script>\n  </tr>\n  <tr>\n    <td>hogehoge</td>\n    <td>fugafuga</td>\n    <td>piyopiyo</td>\n  </tr>\n</table>"

> Sanitize.fragment(html, Sanitize::Config::CUSTOM) # 👈 sanitize.rbで定義した定数を使用
=> "\n<h1>header1</h1>\n<h2>header2</h2>\n<h3>header3</h3>\n\n<a href=\"mailto:test@example.co.jp?subject=件名\">お問い合わせ</a>\n<a href=\"https://www.google.com/?hl=ja\">Google</a>\n<a>aタグに埋め込まれたJS</a>\n\n<div>\n  <div>\n    <div>\n      \n    </div>\n  </div>\n</div>\n\n<table>\n  <tbody><tr>\n    <td>hoge</td>\n    <td>fuga</td>\n    <td>piyo</td>\n    \n  </tr>\n  <tr>\n    <td>hogehoge</td>\n    <td>fugafuga</td>\n    <td>piyopiyo</td>\n  </tr>\n</tbody></table>"
```

サニタイズ後のHTMLは下記のようになりました。

```html:sanitized.html
<h1>header1</h1>
<h2>header2</h2>
<h3>header3</h3>

<a href=\"mailto:test@example.co.jp?subject=件名\">お問い合わせ</a>
<a href=\"https://www.google.com/?hl=ja\">Google</a>
<a>aタグに埋め込まれたJS</a>

<div>
  <div>
    <div>
      
    </div>
  </div>
</div>

<table>
 <tbody>
    <tr>
      <td>hoge</td>
      <td>fuga</td>
      <td>piyo</td>
    </tr>
    <tr>
      <td>hogehoge</td>
      <td>fugafuga</td>
      <td>piyopiyo</td>
    </tr>
  </tbody>
</table>
```

## まとめ

- sanitizeだけではscriptタグ内の文字列が除去されない。
- gem sanitizeは細かな設定が可能で、サニタイズできるgemの中で優位性の高いgemである。