2021-11-02

改めてセッションについて学ぶ

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 メソッドを使わないページへのリクエストを送ってみると、以下のような感じでリクエストが飛ぶ

1

見ての通りリクエストヘッダに Cookie が存在しないが、これは初回アクセスなのでそのサイト(今回はlocalhost)に対して送信できるcookieが存在しないため

このリクエストに対するレスポンスは以下の通り

2

Set-Cookie レスポンスヘッダに _rails_session_session=*****; path=/; HttpOnly; SameSite=Lax という値が返されており、ブラウザのcookieにも記録されていることがわかる↓

3

さらに同じ画面をリロードして再度リクエストを投げてみると、以下の通り先ほどサーバーから受け取ったcookieを Cookie リクエストヘッダにセットして送信していることがわかる

4

再リクエストに対するレスポンスは以下の通り

5

サーバー側でセッション情報を操作していないにも関わらず、値が変更されているようだ
それに伴いブラウザに記録されている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 と言う感じで pathHttpOnlySameSite という属性が付与されているので、これは何なのか確認していく

MDN Web Docs

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

この状態でリクエストすると、レスポンスは以下の通り

6

上述した _app_name_session に加えて自分で追記したcookieが送信されてきた
しかし、1つ SameSiteNone のcookieだけはエラーになりブロックされてしまった

7

どうやらChromeのバージョン80からこの仕様になったっぽい
クロスドメインなアプリでcookieを相互で使っている場合は対策しないと問題が発生する
この挙動はChromeだけの独自仕様なので、Safariなど他のブラウザではちゃんとcookieがセットされる(以下はSafariで試した結果)

8

従ってChromeに言われた通り、 Secure 属性を付与して送信すればブラウザに保存される

cookies[:none] = { value: "none", same_site: "None", path: '/', secure: true }

この場合MDNに説明がある通り、SSL環境でのみcookieが送信されるようになる
…はずなのだけど、普通にSSLではない開発環境でやったら送信できてしまった
調べてみたらlocalhostは例外で除外されるとのことらしい
正直開発環境で気づけなくて、デプロイ後にトラブル発生するので例外扱いしないで欲しい気もする(本番がSSL対応してないとか今時考えられないけど)

SameSiteの動作確認

ローカルの検証環境は localhost なので、クロスドメインにするためにちょっとcookieをいじる

  1. Railsからは以下のように普通にcookieを送信する

    cookies[:lax] = { value: "lax", same_site: "Lax", path: '/' }`
  2. Chromeの検証ツールからRailsから送られてきたcookieの domainexample.com に変更する
  3. viewに example.com へのリンクを配置してリンクをクリックする
    ネットワークタブの「キャッシュを無効化」にチェックを入れないと、リクエストヘッダが全て見られなかったのでチェックする

これで検証した結果は以下の通り

9

  • SameSite: None のcookieが送信されている => 常に送信する設定+SSL環境なのでOK
  • SameSite: Lax のcookieが送信されている => GETなのとtop-level navigationな遷移なのでOK
  • SameSite: Strict のcookieは送信されていない => クロスドメインなのでOK

ちゃんと設定の通り動作している!

Lax のためにGET以外のリクエストも見てみたいので、適当に form_with を用意して POST してみた結果は以下の通り

10

  • SameSite: None のcookieが送信されている => 常に送信する設定+SSL環境なのでOK
  • SameSite: Lax のcookieが送信されていない => POSTなのでOK
  • SameSite: 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が送信され、ブラウザに保存される

11

12

また、 / へのアクセスでは、cookieは送信されてくるものの、ブラウザには対象となるpathのcookieのみが保存される

13

14

まとめ

なんかセッションというより CookieSet-Cookie の検証記事みたいになってしまったけど、Railsがいかに裏側でいい感じに安全対策をやっていてくれるのか、改めて感じることができた
今回の内容をまとめると

  • RailsのsessionはXSSやCSRFといった攻撃をできる限り防止する属性を付与して、ユーザーのブラウザにcookieとして保存し、アクセス毎にやりとりしている
  • SameSite 属性の None を使う場合は、Chromeだけ挙動が異なるので注意が必要

本当に勉強になるから、少しずつでもいいからRailsのコード読もう

参考記事