改めてセッションについて学ぶ
Webアプリケーションの開発をしているとほぼ必ずログイン機能を実装すると思いますが、その時にセッションという言葉が必ず現れます
セッションがどう言うもので、何をやっていて、何のために必要なのかは理解しているのですが
とても大切な概念の割に記事にしてなかったので、改めてRailsのセッション周りの機能を見ながら、どういったものなのかを書いておこうと思う
セッションとは
インターネットでセッションについて調べると大体「ユーザーがWebサイトを表示して、サイトから離脱するまでの一連の流れのこと」と書かれているが
今回の記事では「ステートを持てないHTTPにおける状態の維持」という意味でセッションという言葉を使う
具体的には以下のような表現をする
- セッションの作成
- サーバー側でとあるキーと値のペアを作成し、ブラウザに保存するようレスポンスを返すこと
- セッションの破棄
- ブラウザに保存したキーと値のペアを破棄すること
ちょっと正確な表現が難しいけどこんな感じ
主にログインの状態を判定するために、何らかのデータをユーザーのブラウザに保存させることを示す
Railsにおけるセッション
Railsではコントローラとviewで session
というメソッドを使えるようになっていて、簡単にセッション情報を扱えるようになっている
session[:user_id] = current_user.id
とすれば、暗号化された上でレスポンスヘッダの Set-Cookie
にセットされてブラウザへと送られる(キーは _アプリ名_session
)
暗号化されているため、ユーザー側で改竄することはできない
ユーザーからのリクエスト時はリクエストヘッダの Cookie
に、ブラウザに保存してあって、対象のサーバーに送信可能なcookieを入れて送信してくれるので、Rails側では
session[:user_id]
を見てどのユーザーからのリクエストなのかを判断することができる
これが基本的な流れ
実際のリクエストとレスポンスを見る
とりあえず空っぽのRailsアプリを用意したのでそれを使ってみていく
まずは session
メソッドを使わないページへのリクエストを送ってみると、以下のような感じでリクエストが飛ぶ
見ての通りリクエストヘッダに Cookie
が存在しないが、これは初回アクセスなのでそのサイト(今回はlocalhost)に対して送信できるcookieが存在しないため
このリクエストに対するレスポンスは以下の通り
Set-Cookie
レスポンスヘッダに _rails_session_session=*****; path=/; HttpOnly; SameSite=Lax
という値が返されており、ブラウザのcookieにも記録されていることがわかる↓
さらに同じ画面をリロードして再度リクエストを投げてみると、以下の通り先ほどサーバーから受け取ったcookieを Cookie
リクエストヘッダにセットして送信していることがわかる
再リクエストに対するレスポンスは以下の通り
サーバー側でセッション情報を操作していないにも関わらず、値が変更されているようだ
それに伴いブラウザに記録されているcookieも更新される
ということで、Railsとブラウザはcookieを経由して、裏で情報を常にやりとりしていることが確認できた
cookieに保存したセッション情報を復号してみる
セッション情報は、暗号化してレスポンスヘッダの Set-Cookie
に乗せてユーザーのブラウザに保存し、次のリクエストからは逆にリクエストヘッダの Cookie
に乗せてサーバーへと運ばれる。
という一連のループを回していることがわかった
となると当然、送られてきた暗号化された文字列をサーバー側で復号できないと意味ないので、Railsのどこかに復号化のコードが存在するということになる
が、今回はコードを追わずこちらのgistを拝借する(別途コードを追う)
最後に受け取った値をcookieから取得して、↑のコードにブッ込むと
val=Ms21cOFFA5XFNGHJCztmELvfhIVBFZGRa3lkE0tMALNvuzSeDI1v%2Bc5BzxI728baJpgBsMuVTRSWEcIlTAh7zx2wbjEZn3f%2B0HtulQ2u0umo%2FKlBzZPqNqGqXyam6exLJ5Dm8u1YUbW%2BCJ0LRgbpSsMo4uTXivuH2OJWQ8d2QU9ith376rqK3dlyTmO5SPOTXt0rdTCoZ6buS3f5lxtv8QgJK70sTlzFJumE0neoPtn8hINeEE4MUiZ53ZNGhNOAwbI%2FyZoj1ykFVgkGsaN5SAeqMrt6wbuYo66dTCPA--UmkC2rG3ekGWaAAY--NRdaH4iXRc4T6DAsYGVVEA%3D%3D
bundle exec rails runner decrypt_session_cookie.rb $val
{"session_id"=>"617af0165ba94bd4a1ae4823fcb6ff71", "_csrf_token"=>"7vwgjX8gKBgjNr8LC3KWDf74fc9a3DxlT_qJEMv11H4="}
という結果が得られ、特に何もセッションに関するコードを書かなくても
session_id
と _csrf_token
という2つのキーと値は常にセットされていることがわかった
csrf_token
はDOMに埋め込まれているCSRF攻撃回避のためのものなのはわかるが、 session_id
は何のためにあるんだろう?(調べたけどよくわからなかったので、これもまたどこかで調べる)
ちなみに、先ほどリロードする度に Set-Cookie
で送られてくる値が変化すると書いたが、何度復号しても得られる値は同じだった(間違いなくセキュリティのためだろうけど、詳細な意図はわからず)
Set-Cookieに付与されている属性を見る
Set-Cookie
の値をよく見ると _rails_session_session=*****; path=/; HttpOnly; SameSite=Lax
と言う感じで path
や HttpOnly
や SameSite
という属性が付与されているので、これは何なのか確認していく
path
リクエストのURLに含まれるべきパスで、単に /
の場合はどのパスに対してもcookieを送信するという意味になる
パスを絞りたい場合のみ /hoge
のように指定する
(まぁほとんどの場合 /
で良さそう)
HttpOnly
JSからこのcookieにアクセスすることを禁止する
実際に document.cookie
でアクセスしても空文字しか得られない
(他に取得できるcookieがあれば別)
SameSite
SameSite
は「ドメインを跨いだリクエスト時にcookieをセットするかどうか」を判断するための属性で
SameSite=Lax
は「top-level navigation(アドレスバーに表示されているURLの変更が伴う遷移)であり、かつGETメソッドであれば、他ドメインへのリクエストであってもcookieをセットして送信する」という意味になる
すなわちPOSTやajax通信ではcookieは送信されないことになる
まとめると
Railsのセッションは
- 悪意のあるJSとかが実行されてもcookieを読み取れないようにしている => XSSによる攻撃の防止
- SameSite=Laxなので、悪意のあるJSのajaxなどで別のドメインへcookieを送信しようとしても盗むことはできない => CSRF防止
という効果があるようだ
本当にSet-Cookieの指定通りの挙動になるのか確認する
HttpOnlyはコンソールから簡単に確認できたけど、 path
とか SameSite
は効果を確認できていないので、実際にやってみる
適当なアクションで以下のようにcookieを仕込む
def index
cookies[:lax] = { value: "lax", same_site: "Lax", path: '/' }
cookies[:strict] = { value: "strict", same_site: "Strict", path: '/' }
cookies[:none] = { value: "none", same_site: "None", path: '/' }
end
この状態でリクエストすると、レスポンスは以下の通り
上述した _app_name_session
に加えて自分で追記したcookieが送信されてきた
しかし、1つ SameSite
が None
のcookieだけはエラーになりブロックされてしまった
どうやらChromeのバージョン80からこの仕様になったっぽい
クロスドメインなアプリでcookieを相互で使っている場合は対策しないと問題が発生する
この挙動はChromeだけの独自仕様なので、Safariなど他のブラウザではちゃんとcookieがセットされる(以下はSafariで試した結果)
従ってChromeに言われた通り、 Secure
属性を付与して送信すればブラウザに保存される
cookies[:none] = { value: "none", same_site: "None", path: '/', secure: true }
この場合MDNに説明がある通り、SSL環境でのみcookieが送信されるようになる
...はずなのだけど、普通にSSLではない開発環境でやったら送信できてしまった
調べてみたらlocalhostは例外で除外されるとのことらしい
正直開発環境で気づけなくて、デプロイ後にトラブル発生するので例外扱いしないで欲しい気もする(本番がSSL対応してないとか今時考えられないけど)
SameSiteの動作確認
ローカルの検証環境は localhost
なので、クロスドメインにするためにちょっとcookieをいじる
- Railsからは以下のように普通にcookieを送信する
cookies[:lax] = { value: "lax", same_site: "Lax", path: '/' }`
- Chromeの検証ツールからRailsから送られてきたcookieの
domain
をexample.com
に変更する - viewに
example.com
へのリンクを配置してリンクをクリックする
ネットワークタブの「キャッシュを無効化」にチェックを入れないと、リクエストヘッダが全て見られなかったのでチェックする
これで検証した結果は以下の通り
SameSite: None
のcookieが送信されている => 常に送信する設定+SSL環境なのでOKSameSite: Lax
のcookieが送信されている => GETなのとtop-level navigationな遷移なのでOKSameSite: Strict
のcookieは送信されていない => クロスドメインなのでOK
ちゃんと設定の通り動作している!
Lax
のためにGET以外のリクエストも見てみたいので、適当に form_with
を用意して POST
してみた結果は以下の通り
SameSite: None
のcookieが送信されている => 常に送信する設定+SSL環境なのでOKSameSite: Lax
のcookieが送信されていない => POSTなのでOKSameSite: Strict
のcookieは送信されていない => クロスドメインなのでOK
こちらも意図通りの動作をしている!
ということで SameSite
属性でした
pathの動作確認
これは簡単で、適当なルーティングを用意してアクセスすればよい
def index
cookies[:lax] = { value: "lax", same_site: "Lax", path: '/' }
cookies[:strict] = { value: "strict", same_site: "Strict", path: '/' }
cookies[:none] = { value: "none", same_site: "None", path: '/' }
cookies[:lax_path] = { value: "lax_path", same_site: "Lax", path: '/' }
cookies[:strict_path] = { value: "strict_path", same_site: "Strict", path: '/' }
cookies[:none_path] = { value: "none_path", same_site: "None", path: '/' }
end
これで /posts
へアクセスすると、以下の通り全てのcookieが送信され、ブラウザに保存される
また、 /
へのアクセスでは、cookieは送信されてくるものの、ブラウザには対象となるpathのcookieのみが保存される
まとめ
なんかセッションというより Cookie
と Set-Cookie
の検証記事みたいになってしまったけど、Railsがいかに裏側でいい感じに安全対策をやっていてくれるのか、改めて感じることができた
今回の内容をまとめると
- RailsのsessionはXSSやCSRFといった攻撃をできる限り防止する属性を付与して、ユーザーのブラウザにcookieとして保存し、アクセス毎にやりとりしている
SameSite
属性のNone
を使う場合は、Chromeだけ挙動が異なるので注意が必要
本当に勉強になるから、少しずつでもいいからRailsのコード読もう
参考記事
- https://railsguides.jp/security.html
- https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Set-Cookie
- https://www.slideshare.net/carotene4035/sessionrailscookie-store
- https://www.infraexpert.com/study/tcpip16.6.html
- https://laboradian.com/same-site-cookies/
- https://numb86-tech.hatenablog.com/entry/2020/01/26/112607