RackのSessionもしくはCookieに含まれる内容について私的理解(Sinatra利用向け)

Sinatra で簡単なサイトを構築しようと思いつつ、理解力がないためになかなか進まない[twitter:@riocampos]です。

Session?

再度書きますが私的理解です。誤解を含んでいるかも知れません。

  • Ruby on Rails でも Sinatra でも、Rack というものの上に立っている。
  • Web サイトではセッションがないとサイト遷移により情報が潰えてしまう。
  • セッション情報の保持には、Rack の場合 Rack::Session 以下のクラスで扱っているものを使用する。
  • ネットなどでいちばん良く説明されているのが Rack::Session::Cookie だが、セッション情報の全てを Cookie へ入れてしまうので盗まれた場合の危険性が高い。 Rack::Session::Memcache では、セッション ID のみ cookie に、その他のセッション情報が memcached に保存される(もちろん memcached が動いている必要がある)。 Rack::Session::Pool でも同様に、セッション ID のみ cookie に、その他のセッション情報がインスタンス変数 @pool に保持される(当然ながらアプリを終えれば消える)。

→ セッションID、及び全てのキー・バリューのペアをCookieに保存する。

  • Rack::Session::Pool

→ セッションIDのみクッキーに保存する。データはRack::Session::Poolのインスタンス変数@poolに、メモリ上で永続化の処理をせず(つまりそのまま)保存される。そのため高速で動作し、かつ保存できるオブジェクトに(永続化しないため)制約がない。ただし、アプリケーションを再起動した場合にはデータは全て失われる。

  • Rack::Session::Memcache

→ セッションIDのみクッキーに保存する。データはmemcachedにより保存するため、当然memcachedは導入済みである必要がある。おそらく、永続化の処理は入るため保存できるデータの制約はRack::Session::Cookieと一緒と思われる。高速で動作する。速度的にはRack::Session::Poolとほぼ同等(永続化の操作の分わずかに劣ると思うが)。アプリケーションを再起動してもデータは保持される。
速度性能を追求するかどうか、そしてアプリケーションの再起動時にデータを保存する必要があるかどうか、が使い分けのポイントですかね。
河西 高明 Tech Blog: Rack::Session::PoolとRack::Session::Cookieの違い

その他、保存先が MongoDB になる rack-session-mongo gem というのもあるようです*1API の構成は Rack::Session::Memcache や Rack::Session::Pool と同じなので、使い方に悩むことは全くないと思います。

情報源:ruby - Using `Rack::Session::Pool` over `Rack::Session::Cookie` - Stack Overflow

6/18追記:保存先が redis になる gemが二つ。
◎rack-session-redis gem

情報源:sinatraでセッション管理をredisでやろうとしたらちょっと困った話 - blog.youyo.info

◎redis-store gemと redis-rack gem

情報源:セッションの保存先にRedisを使う - #詰んでる日記

このgemを使うとRailsとかSinatraとかRackとかのキャッシュやセッションの保存先にRedisを使うようにすることができる。

ただしこれらの gem も更新されていないので、動くかどうか確認しましょう。
6/18追記ここまで

Sinatra での Sessionの使い方

まずは Sinatra 本家の FAQ から。

enable :sessions

を使えば良いよ、ということ。
session ハッシュに何か入れると、Rack::Session::Cookie により cookie を通じてセッションを越えて情報を送れます。

How do I use sessions?

Sessions are disabled by default. You need to enable them and then use the session hash from routes and views:

enable :sessions

get '/foo' do
  session[:message] = 'Hello World!'
  redirect to('/bar')
end

get '/bar' do
  session[:message]   # => 'Hello World!'
end

If you need to set additional parameters for sessions, like expiration date, use Rack::Session::Cookie directly instead of enable :sessions (example from Rack documentation):

use Rack::Session::Cookie, :key => 'rack.session',
                           :domain => 'foo.com',
                           :path => '/',
                           :expire_after => 2592000, # In seconds
                           :secret => 'change_me'

Sinatra: Frequently Asked Questions

で。
enable :sessions だけだと必ず警告が出ます。

        SECURITY WARNING: No secret option provided to Rack::Session::Cookie.
        This poses a security threat. It is strongly recommended that you
        provide a secret to prevent exploits that may be possible from crafted
        cookies. This will not be supported in future versions of Rack, and
        future versions will even invalidate your existing user cookies.

なので、secret オプションを付けたいですよね。

1. Sinatra の範囲で secret オプションを設定

enable :sessions の代わりに

set :sessions,
  secret: 'set_your_secret_key'

と設定します。
参考:SinatraでSessionを利用する · twopipe

ちなみに、enable :sessions

set :sessions, true

シンタックスシュガーです。
参考:Sinatra - Configuration の邦訳 | FreeStyleVision

2. Rack の範囲で secret オプションを設定

enable :sessions の代わりに

use Rack::Session::Cookie,
  secret: 'set_your_secret_key'

と設定します。
Rack::Session::Memcache / Rack::Session::Pool、または rack-session-mongo gem を導入して Rack::Session::Mongo を使う場合、などでは、この use Rack::Session::Cookie の代わりにそれぞれのクラスを指定すれば良いでしょう。

ちなみに Rack で用いられる

use Rack::Session::Cookie, ...

use ですが、

useはRack::Builderで用意されたDSLの記法で,後続のミドルウェアやアプリケーションを引数に取ってnewしてHandlerの引数にする,ということをおこなっています。
第25回 Rackとは何か(3)ミドルウェアのすすめ:Ruby Freaks Lounge|gihyo.jp … 技術評論社

ということだそうです。

Rack::Session::Pool を使う例('15/2/21追記)

use Rack::Session::Pool,
  path: '/',
  domain: nil,
  expire_after: 60 * 10, # Cookieの有効期限を10分に
  secret: Digest::SHA256.hexdigest(rand.to_s)

余談ですが Digest::SHA1.hexdigest(rand.to_s) はもう古いので止めましょうね。

cookieの中身を確認

Sinatra 本家の FAQ に倣って、次のようなスクリプトをローカルで動かしてみます。

require 'sinatra'
require 'sinatra/reloader'
require 'pp'

set :sessions, 
  secret: 'xxx'

get '/foo' do
  session[:message] = 'Hello World!'
  pp session
  redirect to('/bar')
end

get '/bar' do
  pp session
  session[:message]   # => 'Hello World!'
end

Chrome のシークレットウィンドウで http://localhost:4567/foo へアクセスすると即座に http://localhost:4567/bar へ転送されます。
そのときのログはこのようになりました(読みやすいように pp session の出力に改行を加えました)。

{
  "session_id"=>"30071129b53a4fda0a5b66432fd9e3eeaa1cc124b1aa30b7e35ffac41f07e768", 
  "csrf"=>"9eaa397b3689b0c09c20e339b8f95618", 
  "tracking"=>{
    "HTTP_USER_AGENT"=>"9637f8f4698bacb96d868e6d21c5775564123002", 
    "HTTP_ACCEPT_LANGUAGE"=>"2668ccc0b26c1ada1e450bf13e21e51501a40422"
  }, 
  "message"=>"Hello World!"
}
127.0.0.1 - - [16/Jun/2014 16:44:43] "GET /foo HTTP/1.1" 302 - 0.0013
{
  "session_id"=>"30071129b53a4fda0a5b66432fd9e3eeaa1cc124b1aa30b7e35ffac41f07e768", 
  "csrf"=>"9eaa397b3689b0c09c20e339b8f95618", 
  "tracking"=>{
    "HTTP_USER_AGENT"=>"9637f8f4698bacb96d868e6d21c5775564123002", 
    "HTTP_ACCEPT_LANGUAGE"=>"2668ccc0b26c1ada1e450bf13e21e51501a40422"
  }, 
  "message"=>"Hello World!"
}
127.0.0.1 - - [16/Jun/2014 16:44:43] "GET /bar HTTP/1.1" 200 12 0.0011

そして Chromecookie はこのようになっています。

rack.session=BAh7CUkiD3Nlc3Npb25faWQGOgZFRiJFMzAwNzExMjliNTNhNGZkYTBhNWI2%0ANjQzMmZkOWUzZWVhYTFjYzEyNGIxYWEzMGI3ZTM1ZmZhYzQxZjA3ZTc2OEki%0ACWNzcmYGOwBGIiU5ZWFhMzk3YjM2ODliMGMwOWMyMGUzMzliOGY5NTYxOEki%0ADXRyYWNraW5nBjsARnsHSSIUSFRUUF9VU0VSX0FHRU5UBjsARiItOTYzN2Y4%0AZjQ2OThiYWNiOTZkODY4ZTZkMjFjNTc3NTU2NDEyMzAwMkkiGUhUVFBfQUND%0ARVBUX0xBTkdVQUdFBjsARiItMjY2OGNjYzBiMjZjMWFkYTFlNDUwYmYxM2Uy%0AMWU1MTUwMWE0MDQyMkkiDG1lc3NhZ2UGOwBGSSIRSGVsbG8gV29ybGQhBjsA%0ARg%3D%3D%0A--9cb3bf80af8ff28e027e9b59c7009f97cac215e6

では、Ruby - Rails で (Rack の) セッション情報を Cookie に保存する仕組み - Qiita に従って、この cookie の中身を確認してみます。

URI デコードと -- での切り出し
require 'base64'
require 'uri'
require 'openssl'
session_in_cookie = "BAh7CUkiD3Nlc3Npb25faWQGOgZFRiJFMzAwNzExMjliNTNhNGZkYTBhNWI2%0ANjQzMmZkOWUzZWVhYTFjYzEyNGIxYWEzMGI3ZTM1ZmZhYzQxZjA3ZTc2OEki%0ACWNzcmYGOwBGIiU5ZWFhMzk3YjM2ODliMGMwOWMyMGUzMzliOGY5NTYxOEki%0ADXRyYWNraW5nBjsARnsHSSIUSFRUUF9VU0VSX0FHRU5UBjsARiItOTYzN2Y4%0AZjQ2OThiYWNiOTZkODY4ZTZkMjFjNTc3NTU2NDEyMzAwMkkiGUhUVFBfQUND%0ARVBUX0xBTkdVQUdFBjsARiItMjY2OGNjYzBiMjZjMWFkYTFlNDUwYmYxM2Uy%0AMWU1MTUwMWE0MDQyMkkiDG1lc3NhZ2UGOwBGSSIRSGVsbG8gV29ybGQhBjsA%0ARg%3D%3D%0A--9cb3bf80af8ff28e027e9b59c7009f97cac215e6"
session_base64, digest = URI.decode(session_in_cookie).split("--")
# => ["BAh7CUkiD3Nlc3Npb25faWQGOgZFRiJFMzAwNzExMjliNTNhNGZkYTBhNWI2\nNjQzMmZkOWUzZWVhYTFjYzEyNGIxYWEzMGI3ZTM1ZmZhYzQxZjA3ZTc2OEki\nCWNzcmYGOwBGIiU5ZWFhMzk3YjM2ODliMGMwOWMyMGUzMzliOGY5NTYxOEki\nDXRyYWNraW5nBjsARnsHSSIUSFRUUF9VU0VSX0FHRU5UBjsARiItOTYzN2Y4\nZjQ2OThiYWNiOTZkODY4ZTZkMjFjNTc3NTU2NDEyMzAwMkkiGUhUVFBfQUND\nRVBUX0xBTkdVQUdFBjsARiItMjY2OGNjYzBiMjZjMWFkYTFlNDUwYmYxM2Uy\nMWU1MTUwMWE0MDQyMkkiDG1lc3NhZ2UGOwBGSSIRSGVsbG8gV29ybGQhBjsA\nRg==\n",
 "9cb3bf80af8ff28e027e9b59c7009f97cac215e6"]
Base64 デコードして Marshal.load でオブジェクトを復元
Marshal.load(Base64.decode64(session_base64))
# => {"session_id"=>
  "30071129b53a4fda0a5b66432fd9e3eeaa1cc124b1aa30b7e35ffac41f07e768",
 "csrf"=>"9eaa397b3689b0c09c20e339b8f95618",
 "tracking"=>
  {"HTTP_USER_AGENT"=>"9637f8f4698bacb96d868e6d21c5775564123002",
   "HTTP_ACCEPT_LANGUAGE"=>"2668ccc0b26c1ada1e450bf13e21e51501a40422"},
 "message"=>"Hello World!"}

ということで、ログと全く同じ情報を得ることが出来ました。
つまり、enable :sessions つまり Rack::Session::Cookie を使った場合、cookie には、セッション情報も、session ハッシュの値も、全て含まれていることが分かりました。

秘密鍵 secret でハッシュ値が作られているか確認

上のコードでさらっと split しているが、Cookie に格納される情報は Marshal.dump -> Base64 エンコードした文字列のあと -- に続いて、ハッシュ値が書かれている。これは -- より前の文字列と秘密鍵を使って OpenSSL::HMAC.hexdigest で作成したもので、Cookie 生成時に付与され、受け取ったときに一致するか検証される。
このため、セッションの中身を書き換えたところで、秘密鍵を知らない限り、適切なハッシュ値を付与することができず、改ざんを防げるわけだ。
この秘密鍵Rails アプリケーション作成時にランダムに生成される…
Ruby - Rails で (Rack の) セッション情報を Cookie に保存する仕組み - Qiita

今回は Rails じゃなく Sinatra ですし、秘密鍵は先ほどのスクリプトにも記載したように

set :sessions, 
  secret: 'xxx'

です。これを使って cookie のハッシュを作ってみます。

OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, "xxx", session_base64)
# => "9cb3bf80af8ff28e027e9b59c7009f97cac215e6"
OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, "xxx", session_base64) == digest
# => true

等しいですね。ということで同じプロセスをたどっていることになります。
つまり、 :secret で Sinatra / Rack へ渡す値は秘密鍵として用いられること、そのためできるだけ長い方が好ましいことが分かります。

session (cookie)のデフォルト値

rack 1.5.2 の Rack::Session::Abstract::ID クラスには以下のように宣言されています。domainは nil ですね。
ついでにセッション ID についても載せておきます。

class ID
  DEFAULT_OPTIONS = {
    :key =>           'rack.session',
    :path =>          '/',
    :domain =>        nil,
    :expire_after =>  nil,
    :secure =>        false,
    :httponly =>      true,
    :defer =>         false,
    :renew =>         false,
    :sidbits =>       128,
    :cookie_only =>   true,
    :secure_random => (::SecureRandom rescue false)
  }

  attr_reader :key, :default_options

  def initialize(app, options={})
    @app = app
    @default_options = self.class::DEFAULT_OPTIONS.merge(options)
    @key = @default_options.delete(:key)
    @cookie_only = @default_options.delete(:cookie_only)
    initialize_sid
  end
  #:(略)
  private

  def initialize_sid
    @sidbits = @default_options[:sidbits]
    @sid_secure = @default_options[:secure_random]
    @sid_length = @sidbits / 4
  end

  # Generate a new session id using Ruby #rand.  The size of the
  # session id is controlled by the :sidbits option.
  # Monkey patch this to use custom methods for session id generation.

  def generate_sid(secure = @sid_secure)
    if secure
      secure.hex(@sid_length)
    else
      "%0#{@sid_length}x" % Kernel.rand(2**@sidbits - 1)
    end
  rescue NotImplementedError
    generate_sid(false)
  end
  #:(略)
end

参考:Railsのsession_idの決定方法を知る - memo.yomukaku.net

domain とか path とかを付けてトラブる

Sinatra 本家の FAQ に従って domain とか path とかを設定したら、なぜか動かない。
よくよく調べてみたら、domain の設定を間違っていました。コメントアウトしたら session ハッシュに入れた情報を cookie にセット出来ましたし、別ページでも取得出来ました。
ところで、インターネット上の大半のサイトが同様に domain を書いてるのは何でだろう。
そして、domain は設定しない方が良いというのを見つけて驚愕。
ので、domain とか path とかの設定は削除しておきましょう。

たまに誤解があるようですが、Cookieを設定する場合のDomain属性は *設定しない* のがもっとも安全です。

  • Domain属性を指定しないCookieは、Cookieを発行したホストのみに送信される
  • Domain属性を指定したCookieは、指定のホストおよびそのサブドメインのホストに送信される

すなわち、Domain=example.comを指定したCookieは、www.example.comにも送信されます。Domain属性を指定しないCookieは、example.comに送信され、www.example.comには送信されません。
これは、CookieのRFC2965(旧規格)、RFC6265(現規格)には明確に記述されています。
CookieのDomain属性は *指定しない* が一番安全 | 徳丸浩の日記

IPA の文書に誤りがあるとの指摘も含まれており、なんだかガクブルです。
  

*1:ただしバージョンが 0.0.1 で 2012 年 2 月更新なので、動作するか要確認