RailsでのCookieのSameSite, Secureの対応


##  概要
2020年2月にChromeのバージョンが80にアップデートされました。
これはCSRFを防ぐためChromeのCookieのSameSite属性をデフォルトで`SameSite=Lax`にしようというものです。アップデート前は`SameSite=None`と同じ挙動をしていました。

つまりアップデート前まで`SameSite=None`でないと正常に動作しないアプリケーションについては明示的に`None`を指定する必要があります。

https://developers-jp.googleblog.com/2019/11/cookie-samesitenone-secure.html
から抜粋

>2月のChrome 80 以降、SameSite 値が宣言されていない Cookie は SameSite=Lax として扱われます。
>外部アクセスは、SameSite=None; Secure 設定のある Cookie のみ可能になります。
>ただし、これらが安全な接続からアクセスされることが条件です。

## SameSiteの挙動の説明

| SameSite | 説明 |
|:-----------|:------------------|
| None | クロスドメインでCookieの受け渡しが可能 <br> (ただしSecure=Trueの設定は必須のためhttpの環境だと正常に動作しないはずです)|
| Lax | クロスドメインでGETメソッドであればCookieの受け渡しが可能だがPOSTメソッドは不可 |
| Strict |クロスドメインでGET、POSTメソッド両方ともCookieの受け渡しは不可 |

ただし、これはChromeでの挙動になります。

それ以外のブラウザでは違った挙動をするものがあるので注意が必要です。詳しくは下記のリンク先をご覧ください。
https://www.chromium.org/updates/same-site/incompatible-clients

この中で結構問題があると思われるのは
`Versions of Safari and embedded browsers on MacOS 10.14 and all browsers on iOS 12. These versions will erroneously treat cookies marked with SameSite=None as if they were marked SameSite=Strict. This bug has been fixed on newer versions of iOS and MacOS`
です。
現時点(2020/2/27)でiOS 12、MacOS 10.14はそれほど古いバージョンではなく使用しているユーザーも多いと思われます。このユーザーに対しては`SameSite=None`と設定していると`SameSite=Strict`と同じ挙動をするそうです。
対応するとしたらブラウザによってcookieを書き換える必要がありそうです。

## Ruby on RailsでSameSite=Noneを設定する対応例

こちらの記事ではgemでの対応が記載されているのでgemで対応したい方はこちらが良いと思います。
([rails_same_site_cookie gemで、RailsアプリにChrome 80向けのSameSite属性を指定する](https://qiita.com/jnchito/items/1290a7b1dbf0ccf40c7b))

以降はRuby on Railsでの実装例を記載します。

最初にrack gemのバージョンを確認します。
rackが2.0系だと`SameSite=None`の対応が入っておらずエラーになるのでrackのバージョンアップをおこなうか`Rack::Utils`のモンキーパッチ以下のように作成します。
rackのバージョンアップ時に削除漏れがあるといけないので内容にTODOを記載しておきましょう。

```ruby
# -*- encoding: binary -*-
# TODO: rack2.1.0以降だとsamesite=Noneの設定が入っているのでソースファイルごと削除する
module Rack
  # Rack::Utils contains a grab-bag of useful methods for writing web
  # applications adopted from all kinds of Ruby libraries.

  module Utils
    def add_cookie_to_header(header, key, value)
      case value
      when Hash
        domain  = "; domain=#{value[:domain]}"   if value[:domain]
        path    = "; path=#{value[:path]}"       if value[:path]
        max_age = "; max-age=#{value[:max_age]}" if value[:max_age]
        # There is an RFC mess in the area of date formatting for Cookies. Not
        # only are there contradicting RFCs and examples within RFC text, but
        # there are also numerous conflicting names of fields and partially
        # cross-applicable specifications.
        #
        # These are best described in RFC 2616 3.3.1. This RFC text also
        # specifies that RFC 822 as updated by RFC 1123 is preferred. That is a
        # fixed length format with space-date delimited fields.
        #
        # See also RFC 1123 section 5.2.14.
        #
        # RFC 6265 also specifies "sane-cookie-date" as RFC 1123 date, defined
        # in RFC 2616 3.3.1. RFC 6265 also gives examples that clearly denote
        # the space delimited format. These formats are compliant with RFC 2822.
        #
        # For reference, all involved RFCs are:
        # RFC 822
        # RFC 1123
        # RFC 2109
        # RFC 2616
        # RFC 2822
        # RFC 2965
        # RFC 6265
        expires = "; expires=" +
          rfc2822(value[:expires].clone.gmtime) if value[:expires]
        secure = "; secure"  if value[:secure]
        httponly = "; HttpOnly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only])
        same_site =
          case value[:same_site]
          when false, nil
            nil
          when :none, 'None', :None
            '; SameSite=None'.freeze
          when :lax, 'Lax', :Lax
            '; SameSite=Lax'.freeze
          when true, :strict, 'Strict', :Strict
            '; SameSite=Strict'.freeze
          else
            raise ArgumentError, "Invalid SameSite value: #{value[:same_site].inspect}"
          end
        value = value[:value]
      end
      value = [value] unless Array === value

      cookie = "#{escape(key)}=#{value.map { |v| escape v }.join('&')}#{domain}" \
        "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}"

      case header
      when nil, ''
        cookie
      when String
        [header, cookie].join("\n")
      when Array
        (header + [cookie]).join("\n")
      else
        raise ArgumentError, "Unrecognized cookie header value. Expected String, Array, or nil, got #{header.inspect}"
      end
    end
    module_function :add_cookie_to_header
  end
end
```

修正箇所は↓の部分を追記しただけです。

```ruby
when :none, 'None', :None
            '; SameSite=None'.freeze
```

rackが2.1系以降であれば対応されているので上記の対応は不要です。

使い方はこんな感じでいけると思います。

```ruby
cookies[:hogehoge] = { value: "sample value", expires: 1.hour.from_now, same_site: "None", secure: true }
```


## 環境によって`Secure=True or False`を設定したい場合

以下のようなClassを作成します。

```ruby
# frozen_string_literal: true

class SecureCookieWithSameSiteLax

  def self.secure?
    Rails.env.staging? || Rails.env.production?
  end

end
```

使い方はこんな感じです。

```ruby
secure = SecureCookieWithSameSiteLax.secure?

cookies[:hogehoge] = { value: "sample value", expires: 1.hour.from_now, same_site: "Lax", secure: secure }
```

production環境、staging環境では`SameSite=Lax Secure=True`
development環境では`SameSite=Lax Secure=False`
としたい場合の実装方法となります。

`SameSite=None`を設定しなくても問題なく動作するアプリケーションであれば`Lax`、`Strict`を明示的にセットしてsecureな設定にしておくのが良いと思います。