Opt Technologies Magazine

オプトテクノロジーズ 公式Webマガジン

社内勉強会でCookieの仕様とセキュリティについて話しました

alt

(from http://www.irasutoya.com/2012/11/blog-post_27.html )

先日行った社内勉強会で、cookieの仕様(主にRFC6265)やセキュリティ(主にCSRF)について話したので、その内容をまとめました。 1st party/3rd party cookieについてや、CORS時のpre flightについても記述しています。

はじめに

こんにちは。uryyyyyyyと申します。

今回は、隔週で開催しているオプトテクノロジーズの社内勉強会で話した内容を共有します。

弊社内でもSPAでのフロントエンド設計が進んできた中、改めて認証やセキュリティ周りについて理解しておく必要があると感じたので、このテーマで調べなおしてお話しました。

SPAでのログイン管理やセキュリティ・CORSなどの仕組みについては、Webエンジニアとしては知っておくべきことだと思うのでご参考にして頂ければと思います。

但し書き

この手の話は厳密さが大切なのですが、勉強会で話すようにまとめているため一部厳密には正しくない記述が含まれます。 きちんと理解するにはRFCを読むことをお勧めします。

(幸い日本語訳があってかなり読みやすいです。)

以下、RFCに沿って「サーバー」と「UA」という単語を使いますが、UAはブラウザと言い換えていいと思います。 curlやプログラマブルなクライアントは、この枠外の挙動をさせることも容易なので対象外です。

またブラウザ毎の実装については、過去の他の方の記事を参照していて自分で試せてはいないため、現在では実装が異なっている可能性があります。

cookieの仕様

cookie(webでの状態管理メカニズム)についての仕様は以下のとおりです。今回は主に上2つについて扱います。

cookieの概要

動作イメージは以下の形です。

== サーバ → UA ==

Set-Cookie: SID=31d4d96e407aad42; Path=/; Domain=example.com

== UA → サーバ ==

Cookie: SID=31d4d96e407aad42

(from https://triple-underscore.github.io/RFC6265-ja.html )

サーバーがHTTP(S) Responseに Set-Cookieというヘッダで情報を送ると、UAは後続のHTTP(S) Requestにおいて、Cookieというヘッダにその情報を載せてサーバーに返します。

ただし、UAはSet-Cookieを無視しても構わないし、任意のタイミングで内容を破棄することもできます。

cookieの属性

属性は、UA側でのCookieの管理方法を規定するためにあります。

サーバーからのSet-Cookieの情報の一部として付与されるので、UAはそれに従います。 (UA側からのリクエスト時には属性は付与されません)

(※以下、意訳です。各項目の詳細はRFC参照のこと。)

  • Path
    • 異なるパスで送るCookieを制御できるため、pathによってサービスが違う場合に使えます
    • ただし、異なるパスへのCookie付与はできてしまうため、完全性は保証されません
  • Domain
    • 指定すると、下位ドメインにまたがってCookieを扱えます
    • サブドメインと共有したい意図がなければ指定しないべき
  • Secure
    • 一般にはSSL通信専用として扱われます
    • セッション情報を管理するなら必須
  • Expires
    • 指定しなければ、セッション(定義はブラウザ次第)が終われば破棄されます
  • Max-Age
    • ↑とほぼ同じです。
  • HttpOnly
    • jsなどで操作することを許可しません
    • XSSなどで悪意のあるスクリプトによって値が読み取られることを防げます

Cookieは「セッションのための秘密のトークンを保持する」という目的で扱われることが多いので、 適宜安全側に設定しておく必要があります。

ちなみにplay2-authのデフォルトはこのような形です。

  lazy val tokenAccessor: TokenAccessor = new CookieTokenAccessor(
    cookieName = "PLAY2AUTH_SESS_ID",
    cookieSecureOption = play.api.Play.maybeApplication.exists(app => play.api.Play.isProd(app)),
    cookieHttpOnlyOption = true,
    cookieDomainOption = None,
    cookiePathOption = "/",
    cookieMaxAge = Some(sessionTimeoutInSeconds)
  )

(from https://github.com/t2v/play2-auth/blob/a15dbaf6d1a984bfd2fa09f877ea964df573bb3b/module/src/main/scala/jp/t2v/lab/play2/auth/AuthConfig.scala#L51 )

Applicationがprodで動いている場合のみsecure属性を付けるようになってくれていますね。 開発時はhttps用意するの面倒なので便利です!

cookieのブラウザ毎の差異

ここからはcookie関連のブラウザごとの挙動の違いを見ていきます。

(※動作確認はほとんど行えておらず、また他記事の参照になっているため、現在では挙動が異なる可能性があります。)

Path 属性

Netscapeの仕様とRFC6265においてpathの仕様が異なり、古いブラウザ(主にIE)では互換性のためNetscapeの仕様を維持しています。

  • RFC6265

'/foo' というパスのクッキーがあった場合、"/foo", "/foo/", "/foo/bar" といった url のケースではクッキーが送信されるが、"/foobar" などには送信されない。

  • Netscape

単純に前方一致でのみ判定する。"/foo" というパスのクッキーは "/foobar", "/foo/bar" にマッチすべきとう例まであげている。

(from http://please-sleep.cou929.nu/cookie-path-behavior-difference-of-browsers.html )

私見としては特に必要がなければ(異なるパスで独立のサービスを運用しているなど)、パス指定しないのが無難かと思います。

domain 属性

仕様には以下のように書いてあります。

一部の既存の UA は、 Domain 属性が不在であっても, Domain 属性が存在していて,現在のホスト名を含んでいるかのように扱う。 例えば, example.com が Domain 属性の無い Set-Cookie ヘッダを返した場合、これらの UA は,そのクッキーを www.example.com にも誤って送信することになる。

(from https://triple-underscore.github.io/RFC6265-ja.html )

徳丸さんの記事ではこのようにまとめられていました。

実際のブラウザで調査したところ、Domain属性のないCookieの挙動は以下の結果となりました。

IE9 サブドメインにも送信される

Firefox 7.0.1 RFC6265通り

Google Chrome 14.0.835.202 RFC6265通り

Safari 5.1 RFC6265通り

Opera 11.51 RFC6265通り

iモード(P-07A) サブドメインにも送信される

Android 2.3.3 RFC6265通り

(from http://blog.tokumaru.org/2011/10/cookiedomain.html )

ところで、「サブドメインにも送信される」というときに、「co.jp」などのpublic suffixにcookieを付与したらどうなるのか、という話があります。

RFC6265では、「正準化」の中で事前に定義されているpublic suffix(あるいは通信先と異なるドメイン)についてはSet-Cookieは無視されることになっています。

request-host を正準化した結果が domain-attribute に ドメイン合致 しないならば: この手続きを中止する(クッキーをまるごと無視する)。

セキュリティ上の理由から、多くの UA では, public suffix に対応する Domain 属性は却下するように設定されている。 例えば、一部の UA は "com" や "co.uk" などの Domain 属性を却下することになる。

(from https://triple-underscore.github.io/RFC6265-ja.html )

しかし、古いIEなどではpublic suffixでcookieを付与できてしまう不具合(クッキーモンスターバグ)があるようです。

クッキーモンスターバグがある条件では、トークンを用いたCSRF対策をしていても、「IPアドレスを偽装したなりすまし犯行予告」は防げないことになります。その条件とは、主に以下の両方が成立する場合です。 ・地域型JPドメイン名または都道府県型JPドメイン名上にサイトがある ・利用者がIEを使っている

(from http://blog.tokumaru.org/2013/03/csrf-and-cookie-monster-bug.html )

ちなみに、自分がホストするページに信用できないjsが入っている場合でも、任意のcookieを埋めることができてしまうので注意が必要です。

(この話題はWeb広告業界特有の話かと思います。)

ブラウザは3rd partyのSet-Cookieヘッダを無視できると仕様に書かれています。

ここでいう3rd partyとは、「利用者が直接­訪問しているドメインではないドメイン」のことを指します。

例えばexample.comのページにアクセスした際に、tracking.comのtracking pixelが参照されていれば、それのhttp responseに付いてきたSet-Cookieヘッダを無視できます。

ブラウザ毎の3rd party cookiesの受け入れ状況はこのような形です。

動作確認

https://github.com/opt-tech/cookieTrackingSample

akka-httpで作ってみたサンプルです。 コンテンツサーバをs3、トラッキングサーバをlocalhost(akka-http)としています。

動作準備

コードをcloneしてきて、

sbt trackingServer/run

するとサーバーが立ち上がります。 コンソールでEnterを押すと停止します。

3rd-partyの場合

予め、localhostのCookieを消しておきます。

  1. http://s3-ap-northeast-1.amazonaws.com/opt-tech-magazine-public/cookieSpec/3rd_party_tracking.html にアクセス
  2. localhostのトラッキングピクセルを読むときにSet-Cookieが送られる。
  3. 次回以降のアクセスの時にCookieが送られる。
  4. サーバーは受け取ったら標準出力で内容を確認する。
  5. ブラウザでスーパーリロードしてもう一度サーバーの出力を確認する。

Chromeなど3rd-party cookieを許可しているブラウザであれば同じIDが返ってくるはずです。 Safariなど許可してないブラウザでは毎回新しいIDが返ってくるはずです。

1st-partyの場合

予め、s3-ap-northeast-1.amazonaws.comのCookieを消しておきます。

  1. http://s3-ap-northeast-1.amazonaws.com/opt-tech-magazine-public/cookieSpec/1st_party_tracking.html にアクセス
  2. localhostのサーバーからのjsが読まれてcookieを作る。
  3. jsがサーバーへTrackingIDを「cookieではない形式で」サーバーに送る
  4. サーバーは受け取ったら標準出力で内容を確認する。
  5. スーパーリロードしても同じIDでトラッキングできている。

こちらは、cookieが有効であればどのブラウザでも同じIDが返ってくるはずです。

セキュリティ

RFCでセキュリティの項に書かれているものを取り上げて補足していきます。 特性上、cookieだけではなく色々混ざっています。

Ambient 権限

主にCSRFなどの問題に該当します。

3rd partyとの通信において、

  • Set-Cookieヘッダを無視する
  • Access-Control-Allow-Originヘッダを見て、レスポンスをブラウザ側で破棄する

といったことはブラウザでは既に対応されていますが、Cookieを載せたリクエスト自体は行えることになっています。

この際、サーバー側ではそのリクエストが正規のものなのか不正にコールされたものなのか判別できないという問題があります。

pre flightの仕組み

ちなみに、ブラウザはページのドメインとは異なるページへのajaxリクエストの際に pre flightという仕組みがあります。 pre flightが飛ぶ条件は

playの「クロスサイトリクエストフォージェリ対策」によると

・ PUT や DELETE のようなリクエストを使うようブラウザに強制する

・ application/json のようなコンテントタイプを送信するようブラウザに強制する

・ サーバが既に設定したものとは異なる、新しいクッキーを送信するようブラウザに強制する

・ ブラウザがリクエストに追加する通常のヘッダとは異なる、任意のヘッダを設定するようブラウザに強制する

の場合です。 (application/jsonのようなとありますが、application/x-www-form-urlencoded, multipart/form-data、text/plainの場合はpre flightが飛びません。)

この場合、XMLHttpRequestの中でpre flightリクエストでサーバー側にやりとりしていいかの確認を行い、承認が得られなければリクエストを送りません。 (pre flightではhttp methodがOptionになるため、特に設定していなければpre flightが通らず、本来のリクエストは発生しません。)

参考: 独自ヘッダをチェックするだけのステートレスなCSRF対策は有効なのか?

動作サンプル

https://github.com/opt-tech/cookieTrackingSample

先ほどと同じものです。。

コードをcloneしてきて、

sbt trackingServer/run

するとサーバーが立ち上がります。 コンソールでEnterを押すと停止します。

pre flightが飛ばない場合
  1. http://s3-ap-northeast-1.amazonaws.com/opt-tech-magazine-public/cookieSpec/ajax_no_pre_flight.htmlにアクセス
  2. htmlのajaxの中で外部ドメインへのapplication/x-www-form-urlencodedのリクエストを出しているので、pre flightなし。
  3. POSTリクエストを投げる
  4. 処理されて文字列が返ってくるので、それをajaxのcallbackで処理する。
pre flightが飛ぶ場合(json)
  1. http://s3-ap-northeast-1.amazonaws.com/opt-tech-magazine-public/cookieSpec/ajax_pre_flight.htmlにアクセス
  2. htmlのajaxの中で外部ドメインへのapplication/jsonのリクエストを出しているので、pre flightが走る
  3. サーバ側でOPTIONSメソッドに対して対象ドメインを許可する。
  4. pre flightが通ったので、改めてPOSTリクエストを投げる
  5. 処理されて文字列がブラウザに返ってくる。
  6. ヘッダの情報から、ブラウザで処理しても問題ないと判断してjsが処理する。ここではalertの文言を出す。
pre flightが飛ぶ場合(独自ヘッダ)
  1. http://s3-ap-northeast-1.amazonaws.com/opt-tech-magazine-public/cookieSpec/ajax_pre_flight2.htmlにアクセス
  2. htmlのajaxの中で、独自ヘッダ(X-Requested-With)を付けて外部ドメインへリクエストを出しているので、pre flightが走る
  3. サーバ側でOPTIONSメソッドに対して対象ドメインを許可する。
  4. pre flightが通ったので、改めてPOSTリクエストを投げる
  5. 処理されて文字列がブラウザに返ってくる。
  6. ヘッダの情報から、ブラウザで処理しても問題ないと判断してjsが処理する。ここではalertの文言を出す。

セッション識別子

セッション固定攻撃

セッション固定攻撃では、ログイン前に識別子をつけておいて、ログイン後もその識別子で区別する実装の場合に該当します。

その場合にセッションを乗っ取られる恐れがあります。 (まともに設計していれば問題ないと思います。)

play2-authのデフォルトでは、セッション開始時に独自の識別子を発行するので問題ありません。

def gotoLoginSucceeded(userId: Id, result: => Future[Result])(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = for {
  token <- idContainer.startNewSession(userId, sessionTimeoutInSeconds)
  r     <- result
} yield tokenAccessor.put(token)(r)

(from https://github.com/t2v/play2-auth/blob/80f780271fa4ff6a3b91bce11b49c3bb86012a14/module/src/main/scala/jp/t2v/lab/play2/auth/LoginLogout.scala#L16 )

完全性

サブドメイン、あるいは異なるパス、あるいは非HTTPS通信からのレスポンスを偽装されうる場合、任意のCookieを付与することはできてしまいます。 (例:cookieの値がscriptとして評価されうる場合はXSS脆弱性になる。)

・Cookieはポートやプロトコル(http/https)をまたがって共有されている

・Cookieのsecure属性は平文でCookieを送信しないという設定であり、Cookieをセットする(受信する)場合には効果がない

・既にsecure属性つきCookieがあっても、HTTPのsecure属性なしCookieで上書きされる(IE10、Google Chrome、Firefoxで確認)

(from http://blog.tokumaru.org/2013/09/cookie-manipulation-is-possible-even-on-ssl.html )

まとめ

今回、改めてcookieの仕様についておさらいしてみました。 社内勉強会では、引き続き色々な内容について議論されると思うので、またこちらで共有させてもらいたいと思います。