30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

SameSite 属性を使った Cookie のセキュアな運用を考える

Cookie に対しては「属性」というものを設定することができる。そして属性の設定内容によって、Cookie の生存期間を指定したり、送付先の制限を行ったりすることが可能になっている。属性のひとつであるSameSiteは、正しく使うことでセキュリティ対策やプライバシー保護に大きな効果を発揮する機能である。

ただ他の属性に比べると若干複雑で、しかも比較的新しい属性ということもあって、今のところそれほど普及していない。
しかし有用な機能であることは間違いないし、後述するように一部のブラウザベンダはSameSiteの利用を促す方針を明確にしている。
今後はSameSite属性を積極的に活用していくべきだろう。

この記事では、SameSite属性を設定した Cookie がどのように動作するのかを見ていき、その運用方法について考えていく。

Cookie の基本的な仕組みや機能については、こちらを参照。
numb86-tech.hatenablog.com

Cookie の課題

Cookie の仕様はRFC 6265というドキュメントで定められているが、SameSite属性については記述されていない。
SameSiteはまだドラフトで、仕様を策定している段階だからだ。
関連するドラフトはいくつかあるのだが、ここではRFC 6265bisの内容を参照しながら話を進めていく。
これはRFC 6265の改訂版のドラフトで、SameSiteについても記述してある。

Cookie の特徴は、対象となるドメインへの HTTP リクエストに自動的に付与されることである。明示する必要なく、予めサーバから渡しておいたデータをリクエストに含めてくれる。
これが大きな利便性を生む反面、問題も生み出してきた。
リクエストが発生してしまえば、それがユーザーの意図したものであろうとなかろうと、Cookie も送信されてしまう。この仕組みを悪用されることで、ユーザーが意図しない操作(SNS への不適切な投稿やパスワードの変更など)が行われたり、個人情報が不当に収集されたりしてきた。

Cookie の仕組みを悪用した攻撃に対してウェブアプリケーション側も当然対策を行っており、その知見がネットや書籍で共有されている。だがそれらはどうしても、対症療法的なものにならざるを得なかった。
「ユーザーが意図しない形での Cookie の送信」が発生することを前提とし、そのリクエストの正当性をチェックするというのが基本的なアプローチになる。具体的な方法はここでは説明しないが、トークンの付与やプリフライトリクエストの利用などがよく使われる。プリフライトリクエストによる対策については以前ブログに書いた。

numb86-tech.hatenablog.com

これに対して、「ユーザーが意図しない形での Cookie の送信」をそもそも発生させないようにする、というのがSameSite属性の特徴である。
SameSiteを設定するだけであらゆる攻撃を防げるわけではもちろんないが、適切に使えばそれだけで大きな効果を得ることができる。

SameSite 属性に設定できる値

現在、SameSite属性に設定できる値は以下の 3 つ。

  • Strict
  • Lax
  • None

指定方法は他の属性と同様にSameSite=設定値なので、例えばStrictを指定する場合は以下のようになる。

key=value; SameSite=Strict

SameSite属性を設定しなかった場合、もしくはStrictLax以外の値を指定した場合は、Noneとみなされる。もちろん明示的にNoneを指定することもできるし、可読性から考えてもそのほうがよいだろう。

だが Google Chrome においては、2020 年 2 月にリリース予定のバージョン80から、この挙動が変わる。
SameSite属性なし、もしくはStrictNone以外の値を設定した場合は、Laxとして扱うようになる。
つまり明示的にNoneを指定した場合にのみNoneとして扱われるようになり、さらにNoneの使用に対して独自の制限を設ける。
バージョン80以降の Google Chrome の方針については、この記事の後半で詳しく説明する。

SameSite属性に設定した値と、リクエストの種類。その組み合わせによって Cookie を付与するかどうか判断するというのが、SameSite属性の機能である。
どこからのリクエストなのか、リクエストメソッドは何か、などを見て、このリクエストについてはNoneが設定された Cookie のみを付与しよう、こっちのリクエストはNoneLaxの Cookie を付与しよう、という具合に判断していく。この機能よって、必要以上に Cookie の送信を行わない、より安全なウェブアプリケーションを構築することが可能になる。

では、具体的にどのようなルールで、Cookie を付与するかどうか判断しているか。
ここからは、そのルールを理解するために重要となるいくつかの概念を説明していく。
遠回りに思えるかもしれないが、まずはこれらの概念を理解しておくことで、実際のブラウザの挙動やコードが理解しやすくなる。

same-site と cross-site

まず最初に理解しておきたいのは、same-sitecross-siteという概念である。
これはリクエストを、それがどこから発生したのかによってsame-siteもしくはcross-siteのいずれかに分類するというもの。
ドメインが同じであればsame-siteであり、ドメインが異なればcross-siteとなる。

例えばhttp://a.com/topにあるリンクを踏んでhttp://a.com/menuに移動する場合、同じa.comというドメインからhttp://a.com/menuに対するリクエストが発生しているため、これはsame-siteである。
http://b.com/topにあるリンクを踏んでhttp://a.com/menuに移動する場合は、b.comという異なるドメインからhttp://a.com/menuに対するリクエストが発生しているので、cross-siteとなる。

オリジンではなくドメインで判断するので、注意する。例えばhttp://a.com/からhttps://a.com/mypageにリクエストが発生する場合は、プロトコルが違うのでオリジンは異なるが、ドメインはどちらもa.comなので、これはsame-siteになる。

アドレスバーに直接 URL を入力することで発生したリクエストも、same-siteとなる。
また、ブラウザ外からページを開いた場合も、same-siteである。例えば、ターミナルで$ open https://a.com/mypageというコマンドを実行すると、ブラウザでhttps://a.com/mypageを開くが、このリクエストもsame-siteとして扱われる。

基本的にはsame-siteからのリクエストは比較的安全であり、SameSite属性もそれを前提に設計されている。
セキュリティやプライバシーの文脈で重要なのはcross-siteからのリクエストであり、そのリクエストに Cookie を含めるかどうかが、論点となる。

safe なメソッド

リクエストが安全なものであるかどうかは、メソッドによってもある程度は判断できる。例えばGETは、対象となるリソースに変更を加えないため、POSTDELETEに比べて安全と言える。

RFC 72314.2.1では、以下の 4 つのメソッドをsafeなメソッドとして定義している。

  • GET
  • HEAD
  • OPTIONS
  • TRACE

RFC 6265bisでも、RFC 7231の内容に準拠して、これらのメソッドをsafeなものとして扱っている(5.3.7.1)。

念の為書いておくと、これらは HTTP のルール上そうなっているという話であり、開発者がこのルールを守らずにGETでリソースの変更を行うようなウェブアプリケーションを作っていれば、そのアプリケーションにおいてはGETは安全とは言えなくなる。
当たり前の話だが、SameSite属性の設定だけを頑張っても、他の部分が雑な作りになっていれば台無しになってしまう。

Top Level Navigation

リクエストがTop Level Navigationであるかも、リクエストの安全性を測る基準のひとつになる。

RFC 6265bisに目を通しても、Top Level Navigationの明確な定義は見当たらなかった。
だが、「アドレスバーに表示されている URL のオリジンのドメインをtop-level siteとする」という記述はあった(5.2.1)。そのため、リクエストした URL がアドレスバーに表示されるような動作が、Top Level Navigationなのだと思われる。
後述する検証結果も、それを裏付けるものだった。

具体的には、http://a.com/にあるリンクを踏んでhttp://b.com/に遷移した場合、アドレスバーにはhttp://b.com/が表示されるので、これはTop Level Navigationである。
http://a.com/内に設置された JavaScript が実行され、そのコードにfetch('http://b.com/')と書かれていた場合、http://b.com/へのリクエストは発生するが、アドレスバーの表示はhttp://a.com/のままである。そのためこのケースは、Top Level Navigationではない。

アドレスバーの表示を基準にするのは、多くのユーザーはアドレスバーに表示されている内容によって、今自分がどのサイトにいるか判断するためである。
一般的なユーザーにとって、そのサイトを信頼するかどうかを判断するために使える唯一の材料が、アドレスバーなのだと言える。

そのため、リクエストした URL とアドレスバーの表示が一致する動作は、一般的なユーザーの直感とも一致している。
逆に、リクエストした URL がアドレスバーに反映されないような動作は、リクエストが行われたことにユーザーが気付かない可能性が高く、意図しない操作が行われてもそれを認識できない恐れがある。

SameSite 属性の基本的なルール

SameSite属性では、ここまで説明してきた概念を使って、リクエストに Cookie を付与するかどうか判定している。
具体的には以下の通り。

Strictの場合、same-siteでは常に付与する。cross-siteでは常に付与しない。
Laxの場合、same-siteでは常に付与する。cross-siteでは、Top Level Navigationかつメソッドがsafeのときにのみ、付与する。
Noneの場合、same-siteでは常に付与する。cross-siteでも常に付与する。

表にまとめると次のようになる。

same-site cross-site
Strict ×
Lax Top Level Navigationかつメソッドがsafeのときのみ◯
None

StrictNoneはシンプルであり、分かりづらいのはcross-site時のLaxだけである。
そのためここからは、cross-site時の実際の挙動を確認していく。

検証のための準備

サーバは Node.js のv12.14.1、ブラウザは以下の 3 つを使って、動作確認した。

  • Google Chrome 79.0.3945.130
  • Safari 13.0.4
  • Firefox 72.0.2

どのブラウザでも同じように動き、ブラウザによる差異はないことを確認した。ただ注意すべき事項もあるので、それについては適宜説明していく。

まず、ローカル環境でサブドメインを使えるようにするため、hosts ファイルを編集する。
Mac の場合は/etc/hostsが hosts ファイルなので、これに以下の内容を追加する。

127.0.0.1       sub.localhost

これで、localhostの他にsub.localhostを使えるようになった。

この検証では、http://localhost:8080/http://sub.localhost:8081/という、2 つのドメインを用意することにする。

続いて、http://localhost:8080/のコードを書く。
このドメインで、ブラウザに Cookie をセットする。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'strict=value; SameSite=Strict',
    'lax=value; SameSite=Lax',
    'none=value; SameSite=None',
  ]);
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write('<h1>Cookie を付与するドメイン</h1>');
  res.write('<p>');
  res.write('<a href="http://sub.localhost:8081">別ドメインのサイト(sub.localhost:8081)に移動する</a>');
  res.write('</p>');
  res.end();
}).listen(8080);

3 種類の Cookie をセットした。分かりやすいように、SameSite属性の値をそのまま Cookie の名前にしている。
このコードに、以下の内容を追加する。これは、http://sub.localhost:8081/のコード。

const html = `
<html>
<head></head>
<body>
<p>
  <a href="http://localhost:8080/">a タグ</a>
</p>
<p>
  <form action="http://localhost:8080/" method="GET">
    <button type="submit">form による GET メソッドでのリクエストを行う</button>
  </form>
</p>
<p>
  <form action="http://localhost:8080/" method="POST">
    <button type="submit">form による POST メソッドでのリクエストを行う</button>
  </form>
</p>
</body>
</html>
`;

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write(html);
  res.end();
}).listen(8081);

このコードを Node.js で実行してサーバを起動し、http://localhost:8080/にアクセスする。
すると Cookie がセットされるので、動作確認のためにリロードしてリクエストヘッダを確認してみる。

GET / HTTP/1.1
Host: localhost:8080
Cookie: strict=value; lax=value; none=value

これはsame-siteなので 3 つ全て送信されている。なお、本記事とは無関係なフィールドについては省略している。

検証の準備が整ったので、表示されているリンクでhttp://sub.localhost:8081/に移動する。
検証は全て、http://sub.localhost:8081/からhttp://localhost:8080/にリクエストを送ることで行う。
ドメインが異なるためcross-siteになる。そのため、Strictは常に送信されない。

a タグ

まずは「a タグ」という文字をクリックしてaタグによるリクエストを発行し、そのリクエストヘッダを調べる。

GET / HTTP/1.1
Host: localhost:8080
Cookie: lax=value; none=value

メソッドはGETなのでsafeである。
そして、アドレスバーにhttp://localhost:8080/が表示されており、リクエストした URL と一致する。よって、Top Level Navigationである。
そのため、NoneだけでなくLaxも送信される。

フォームの送信

次はformタグによるリクエスト。メソッドはGETPOSTかを選べるが、それによって結果が変わる。

まずは「form による GET メソッドでのリクエストを行う」をクリックしたときのリクエストヘッダ。

GET / HTTP/1.1
Host: localhost:8080
Cookie: lax=value; none=value

aタグのときと同じで、safeなメソッドかつTop Level Navigationなので、LaxNoneが送信される。

次は、「form による POST メソッドでのリクエストを行う」をクリックしたときのリクエストヘッダ。

POST / HTTP/1.1
Host: localhost:8080
Cookie: none=value

こちらもTop Level Navigationではあるのだが、リクエストメソッドがPOSTなのでsafeではない。
そのためNoneのみが送信される。

prerender

次はprerenderによるリクエストを試す。

http://sub.localhost:8081/の html のheadタグのなかに、以下のコードを追記する。

<link rel="prerender" href="http://localhost:8080/?prerender" crossorigin="use-credentials">

prerenderとは、リソースを先読みするための機能。
ブラウザは、hrefで指定されたリソースを予め読み込んでおく。そうすることで、リンクを踏むなどして実際にそのリソースに移動するときに、瞬時にそのページを表示することができる。

そのため、実際に表示するページに対するリクエストの他に、先読みするページに対するリクエストも発生する。
今回の例では、http://localhost:8080/?prerenderを先読みしている。クエリをつけたのは、prerenderによるリクエストであることを分かりやすくするためであり、それ以上の意味はない。

しかし今日現在、Firefox や Safari ではこの機能を使うことはできない。
Can I use... Support tables for HTML5, CSS3, etc

そのため、Google Chrome のみで検証している。
そして、開発者ツールで当該リクエストを確認することはできないため、http://localhost:8080/側のコードを書き換えて、リクエストの内容がログに表示されるようにした。
具体的には、以下のコードを書く。

console.log(req.url, req.method, req.headers.cookie);

この状態でサーバを起動し直してhttp://sub.localhost:8081/にアクセスすると、ログに以下の内容が表示された。

/?prerender GET lax=value; none=value

NoneだけでなくLaxも送信されているので、prerendersafeなメソッドでありTop Level Navigationであるということが分かった。
GETなのでsafeなのは当然だが、なぜTop Level Navigationなのか。先読みをしているだけなので、アドレスバーには依然としてhttp://sub.localhost:8081/が表示されている。

prerenderに関するドキュメントには、prerenderを実行すると新しくページを作り、そこに対象のリソースをレンダリングすると書かれている。そしてそのページは非表示であり、ユーザーには見えない。先読みしたページに移動しようとすると、非表示ページにレンダリングしていた内容が、現在見ているページにスワップされる。
このような仕組みであるため、Top Level Navigationになるのだと思う。ユーザーには見えていないだけで、ブラウザは新しくページを開いており、そこにhrefで指定した URL の内容を表示させている。つまり、非表示のページであるということ以外は、アドレスバーに URL を打ち込んで表示しているのと何も変わらない。

開発者ツールで確認できないのも、同じ理由だと思われる。今見ているページのなかでリクエストが発生しているのではなく、新しくページを作ってそこでリクエストしている。だから今見ているページのNetworkタブには表示されない。

iframe 内でのフォームの送信

次は、iframe内でformタグによる送信を行う。
この手法を使うと、ユーザーに気付かれることなくフォームからの送信を行える。

今回のサンプルでは、以下のような仕組みになっている。
http://sub.localhost:8081/からhttp://sub.localhost:8081/iframeへのリンクを用意する。
http://sub.localhost:8081/iframeにはiframeでフォームが埋め込まれており、フォームを読み込むと同時に送信するようにしてある。
しかしiframe内でフォームの送信を行っているので、画面遷移は行われない。そしてスタイルの設定によってiframe自体を非表示にしている。
そのため、フォームによる送信が行われたことにユーザーが気付く可能性は低い。

以下がそのコードだが、かなり長いので、上記の説明が理解できていれば無理して読む必要はない。

const topPageHtml = `
<html>
<head>
</head>
<body>
<p>
  <a href="http://localhost:8080/">a タグ</a>
</p>
<p>
  <a href="/iframe">iframe 内の form からリクエストを行う</a>
</p>
</body>
</html>
`;

const iframeGetHtml = `
<body onload="document.forms[0].submit()">
  <form action="http://localhost:8080/" method="GET">
  </form>
</body>
`;

const iframePostHtml = `
<body onload="document.forms[0].submit()">
  <form action="http://localhost:8080/" method="POST">
  </form>
</body>
`;

const iframePageHtml = `
<p>
  iframe でリクエストを送りました。
</p>
<p>
  <a href="http://localhost:8080">http://localhost:8080 のトップページに移動</a>
</p>
<iframe width="0" height="0" style="visibility: hidden;" src="/iframe-get"></iframe>
<iframe width="0" height="0" style="visibility: hidden;" src="/iframe-post"></iframe>
`;

http.createServer((req, res) => {
  switch(true) {
    case /^\/$/.test(req.url):
      res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
      res.write(topPageHtml);
      res.end();
      break;
    case /^\/iframe-get$/.test(req.url):
      res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
      res.write(iframeGetHtml);
      res.end();
      break;
    case /^\/iframe-post$/.test(req.url):
      res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
      res.write(iframePostHtml);
      res.end();
      break;
    case /^\/iframe$/.test(req.url):
      res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
      res.write(iframePageHtml);
      res.end();
      break;
    default:
      res.writeHead(404);
      res.end();
  };
}).listen(8081);

GETPOSTそれぞれのフォームを用意しているので、http://sub.localhost:8081/iframeにアクセスすると、http://localhost:8080/に対して以下の 2 つのリクエストが発生する。

GET / HTTP/1.1
Host: localhost:8080
Cookie: none=value
POST / HTTP/1.1
Host: localhost:8080
Cookie: none=value

どちらもNoneしか送信されていない。これは、iframe内でのフォームの送信はTop Level Navigationではないためである。

img タグ

次は、imgタグによるリクエスト。srcでパスを指定すれば、それに対するリクエストが当然発生する。

サブドメイン側のコードを以下のようにして再実行してから、http://sub.localhost:8081/にアクセスする。

const html = `
<html>
<head></head>
<body>
<p>
  <a href="http://localhost:8080/">a タグ</a>
</p>
<img src="http://localhost:8080/">
</body>
</html>
`;

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write(html);
  res.end();
}).listen(8081);

safeなメソッドではあるがTop Level Navigationではないので、Noneのみが送信される。

GET / HTTP/1.1
Host: localhost:8080
Cookie: none=value

fetch を使ったリクエスト

最後は、fetchによるリクエストを検証する。
http://sub.localhost:8081/scriptタグを埋め込み、そのなかでfetchを実行する。

const html = `
<html>
<head></head>
<body>
<p>
  <a href="http://localhost:8080/">a タグ</a>
</p>
<script>
fetch('http://localhost:8080/', {credentials: 'include'});
</script>
</body>
</html>
`;

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write(html);
  res.end();
}).listen(8081);

これもTop Level Navigationではないので、メソッドを問わず、Noneしか送信されない。

GET / HTTP/1.1
Host: localhost:8080
Cookie: none=value

SameSite 属性の動作一覧

ここまで検証してきた内容を、表にまとめる。
cross-siteの検証結果しか書いてこなかったが、same-siteでも同じ検証を行っているので、その結果も記載しておく。same-siteの場合はStrictLaxNone全ての Cookie が常に送信されるので、特に覚えることはないのだが。

以下が、Laxが送信されるかどうかの早見表である。

same-site cross-site
a タグ
form get
form post ×
prerender
iframe 内での form get ×
iframe 内での form post ×
img ×
fetch ×

そして既に書いたことの繰り返しになるが、Strictは、same-siteの場合にのみ常に送信される。cross-siteの場合は常に送信されない。
Noneは、same-siteでもcross-siteでも常に送信され、StrictLax以外の値を指定したり、SameSite属性を設定しなかった Cookie は全て、Noneとして扱われる。
これらは全てRFC 6265bisに書かれている内容であり、今回検証した各ブラウザの挙動もそれに沿ったものだった。

だが 2020 年 2 月リリース予定の Google Chrome 80 は、SameSite属性について独自の変更を加えている。

Google Chrome 80 での挙動

具体的な変更点は以下の 2 つ。

  1. SameSite属性が設定されていない、もしくはStrictNone以外の値が指定されている場合は、Laxとして扱う
  2. Noneを指定した場合はSecure属性の設定も必須になる

つまりこれまではデフォルトがNoneだったのがLaxになり、そしてNoneは HTTPS 通信でしか使えなくなる、ということである。
従来の仕様よりもセキュリティを厳しくしていると言える。

この挙動についても、実際に検証してみた。

まだリリース前の機能だが、アドレスバーにchrome://flags/と入力し、SameSite by default cookiesCookies without SameSite must be secureの項目を有効にすることで、試すことができる。

HTTPS 通信が必須なので、OpenSSL で自己署名証明書を用意、秘密鍵(server.key)と証明書(server.crt)を作成した。

以下がコードの全文。

const https = require('https');
const fs = require('fs');

const options = {
  key : fs.readFileSync('./certificate/server.key'),
  cert: fs.readFileSync('./certificate/server.crt')
};

https.createServer(options, (req, res) => {
  res.setHeader('Set-Cookie', [
    'strict=value; SameSite=Strict',
    'lax=value; SameSite=Lax',
    'noSecureNone=value; SameSite=None',
    'secureNone=value; SameSite=None; Secure',
    'invalid=value; SameSite=Foo',
    'noSpecify=value',
  ]);
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write('<h1>Cookie を付与するドメイン</h1>');
  res.write('<p>');
  res.write('<a href="https://sub.localhost:8081">別ドメインのサイト(sub.localhost:8081)に移動する</a>');
  res.write('</p>');
  res.end();
}).listen(8080);

const html = `
<html>
<head></head>
<body>
<p>
  <a href="https://localhost:8080/">a タグ</a>
</p>
<img src="https://localhost:8080/">
</body>
</html>
`;

https.createServer(options, (req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write(html);
  res.end();
}).listen(8081);

Noneの取り扱いや、SameSiteに無効な値を指定したケース(invalid)、SameSiteを設定しなかったケース(noSpecify)などについて、検証していく。

https://localhost:8080/にアクセスすると、Cookie がセットされる。
リロードして、そのリクエストヘッダを確認してみる。

GET / HTTP/1.1
Host: localhost:8080
Cookie: strict=value; lax=value; secureNone=value; invalid=value; noSpecify=value

same-siteなので全ての Cookie が送信されるはずだが、noSecureNoneだけが送信されていない。
このことから分かるのは、Noneを指定してSecure属性は設定しなかった場合、送信先が制限されるのではなく、いかなるリクエストにも含まれない無効な Cookie になってしまうということ。そのため、Noneを指定する場合は必ずSecure属性も設定しなければならない。

次に、「別ドメインのサイト(sub.localhost:8081)に移動する」をクリックして、https://sub.localhost:8081/に移動する。
すると、imgタグによるリクエストが発生する。これは、「safeなメソッドだがTop Level Navigationではないcross-siteのリクエスト」である。

GET / HTTP/1.1
Host: localhost:8080
Cookie: secureNone=value

次に「a タグ」をクリックして、https://localhost:8080/遷移する。
これは、「safeなメソッドかつTop Level Navigationである、cross-siteのリクエスト」である。

GET / HTTP/1.1
Host: localhost:8080
Cookie: lax=value; secureNone=value; invalid=value; noSpecify=value

以上の結果から、SameSite属性を設定しなかった Cookie(noSpecify)や、StrictNone以外の値を指定した Cookie(invalid)は、Laxになること、そしてNoneを指定する場合はSecure属性が必須であることを、確認できた。

ウェブアプリケーションがこの変更によってどの程度影響を受けるのかは、これまでの Cookie の運用によって異なる。同一ドメインでしか Cookie を受け渡ししておらず、SameSite属性を何も設定していないのなら、影響はほとんどないかもしれない。

だが例えそうであったとしても、Cookie の仕組みを悪用した攻撃に対する非常に本質的な対策になるため、SameSite属性は積極的に使っていきたい機能である。
とはいえよく理解せずに使ってしまうと、ユーザービリティを損ねることになってしまう。

read access と write access

単純に考えれば、Strictが一番厳しいのだから、全ての Cookie をStrictにしてしまうのが最も強固である。
しかしそうすると、セッション管理に Cookie を使っている場合、他のドメインからリンクで遷移してきたときにログインしていないと見做されてしまう。

一番最初の検証で確認したように、他のドメインからaタグで遷移した場合、Strictは送信しない。そのためセッショントークンをStrictで保存していた場合、リクエストにセッショントークンが含まれず、ログイン状態ではないと見做されてしまう。同一ドメイン内で遷移し直したりページをリロードしたりすれば、そのリクエストはsame-siteとなるので、セッショントークンは送信されログイン状態になる。だがこれはさすがに使い勝手が悪い。

この問題への対処法としてRFC 6265bisでは、read access用の Cookie とwrite access用の Cookie に分けて管理することを提案している(8.8.2)。

まず、異なる権限を持った 2 種類の Cookie を用意する。ひとつには、GETなどによるリソースの取得のみを許可する。これがread access。もう一方には、それに加えて、リソースの操作も許可する。これがwrite access
そして前者にはLaxを指定し、後者にはStrictを指定する。
こうすれば、他のドメインから遷移してきてもread accessは送信されるためログイン状態が維持される。それでいて、リソースの操作、例えば SNS への投稿やパスワードの変更といったものについては、同一ドメイン内からリクエストした場合にのみ許可されるため、安全性が高まる。

参考資料

Cookie 概説

Cookie とは、HTTP でステートフルなやり取りを実現するために、ブラウザとサーバ間で情報を送受信する仕組みである。

HTTP は本来ステートレスなプロトコルである。そのため、同一のユーザーが連続でリクエストを行っても、それぞれ独立したリクエストであり、「同じユーザーからのリクエストである」とサーバが認識することはできない。
これは例えば、ログイン状態の管理で問題となる。ID とパスワードで認証を行っている場合、リクエストの度に ID とパスワードを送信しなければならない。

Cookie を使うことで、このような事態を解決できる。

まず、サーバがブラウザに対して、Cookie としてどのような情報を保存するのか指示する。具体的には、レスポンスヘッダにSet-Cookieフィールドを含め、そこに Cookie として保存させたい情報を設定する。ログイン状態を管理したい場合は、セッショントークンなどを設定することになる。
このレスポンスを受け取ったブラウザは、サーバからの指示通りの内容で、Cookie を保存する。
そして再びそのサーバに対してリクエストを送る際に、ブラウザが自動的に、Cookie で保存していた情報をリクエストヘッダのなかに含める。そのためサーバは、例えばセッショントークンなどを取得でき、都度 ID やパスワードを受け取らなくてもどのユーザーからのアクセスなのかを認識できる。

このような仕組みで Cookie はステートフルなやり取りを実現させており、セッション管理以外にも、アクセス解析やパーソナライゼーションなどに利用されている。

便利な半面、セキュリティ上のリスクもある。
先程説明したような形で Cookie にセッショントークンを保存しているウェブアプリケーションは多く、Cookie の内容を盗まてしまうと、第三者による成りすましが行われる恐れがある。
また、リクエストの際に自動的に Cookie が付与されるという仕組みを悪用して、ユーザーが意図していない操作を行わせるという攻撃も存在する。この攻撃が成功してしまうと例えば、SNS への不適切な投稿やパスワードの変更などが、ユーザーの知らない間に行われてしまう可能性がある。

また近年は、プライバシー保護の観点から、Cookie の取り扱いが問題になることも多くなってきている。
どのようなサイトを見ているのか、どのような広告をクリックし、何を買ったのか。そういった情報を特定することが、Cookie を使えば可能になるからだ。
EU では Cookie に関する規制が既に存在しており、日本でもリクルートによる Cookie の悪用が大きな話題となった。

このように Cookie は、現在のウェブにとって重要な機能であり、かつ、慎重な取り扱いが求められる機能でもある。
だからこそ、どのような仕組みなのか正しく理解することが重要になる。

Cookie の基本的な機能はRFC 6265で定義され、各ブラウザベンダが実装している。
同じブラウザでも、バージョンアップによって Cookie の取り扱いが変更になる場合がある。プライバシー保護という観点から Cookie への規制が強まっていることは既に書いたが、ブラウザの実装においても、Cookie の利用を制限していこうという傾向にある。

この記事では、ローカル環境で実際に Cookie の操作を行いながら、具体的な挙動を確認していく。
動作環境は、サーバは Node.js のv12.14.1、ブラウザは Google Chrome の79.0.3945.130

まずは基本的な機能を確認し、次にRFC 6265で定義されている各属性について調べていく。

Set-Cookie フィールドと Cookie フィールドの基本

以下のコードを Node.js で実行するとhttp://localhost:8080というサーバが起動するので、ブラウザの開発者ツールのNetworkタブを開いた状態でアクセスする。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'a=xxx',
    'b=yyy',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

Hello Worldと表示されているのを確認し、次は、開発者ツールで HTTP のやり取りを確認する。
以下が、リクエストとレスポンスのヘッダ。今回の記事と無関係なフィールドについては基本的に省略している。

GET / HTTP/1.1
Host: localhost:8080
HTTP/1.1 200 OK
Set-Cookie: a=xxx
Set-Cookie: b=yyy

レスポンスにSet-Cookieフィールドが 2 つあり、その値はそれぞれa=xxxb=yyyになっている。
Cookie は名前=値という形式で指定するので、これでブラウザにはabの 2 つの Cookie が保存された。

ブラウザのページをリロードしてhttp://localhost:8080に再びアクセスする。
すると今回のリクエストヘッダには、初回のアクセス時には無かったCookieフィールドが存在している。

GET / HTTP/1.1
Host: localhost:8080
Cookie: a=xxx; b=yyy

このようにブラウザは自動的に、保存しておいた Cookie をサーバに送信する。

コードを以下のように書き換えて再度サーバを起動してアクセスすると、サーバ側で Cookie を取得できていることを確認できる。

const http = require('http');

http.createServer((req, res) => {
  console.log(req.headers.cookie); // a=xxx; b=yyy
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

JavaScript によるブラウザ側での Cookie の操作

Cookie は、JavaScript を使ってブラウザ側で操作することも可能。
試しにブラウザのコンソールを開いて以下のコードを実行すると、Cookie の内容が書き換わる。

> document.cookie
"a=xxx; b=yyy"
> document.cookie = 'a=foo'
"a=foo"
> document.cookie
"b=yyy; a=foo"

この状態でブラウザをリロードすると、先程はa=xxx; b=yyyだったreq.headers.cookieb=yyy; a=fooになっている。

Set-Cookie による上書き

既に存在する Cookie と同じ名前の Cookie をSet-Cookieで指定した場合、その内容で上書きされる。

以下のサーバにアクセスすると、ブラウザに保存されている Cookie の内容はa=xxx; b=yyyになる。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'a=xxx',
    'b=yyy',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

その後、サーバを以下の内容に書き換えてから起動し直した上で、改めてアクセスする。すると、Cookie 内容はb=yyy; a=123になる。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'a=123',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

Expires と Max-Age

ここからは、RFC 62655.2に書かれてある 6 つの属性について見ていく。
属性も、保存するデータと同じように属性名=値という形式で設定する。

まず、ExpiresMax-Age
予め Google Chrome の設定画面から、設定済みのhttp://localhost:8080の Cookie を削除しておく。

これらは Cookie の生存期間に関する属性。Expiresでは日時を、Max-Ageでは秒数をそれぞれ指定し、それを経過すると自動的にブラウザから削除される。
以下の例では、aは指定された日時を過ぎたら、bは 10 秒が経過したら、ブラウザから削除される。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'a=xxx; Expires=Fri, 17 Jan 2020 15:00:00 GMT',
    'b=yyy; Max-Age=10',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

Expiresに過去の日付を指定したり、Max-Ageに 0 やマイナスの数値を指定したりすると、その Cookie は保存されない。そして既述したように、Cookie の内容は常にSet-Cookieで上書きされる。
そのため、既に存在する Cookie の名前で過去の時間を設定すると、その Cookie はブラウザから消去されることになる。

Domain と Path

Cookie はリクエストに自動的に付与されるが、その挙動を制御するための属性がDomainPath

Domainはその名の通り、Cookie を送るドメインを指定する。
だが現実的には、この属性を設定することはほとんどないはず。
設定しない場合、Cookie を発行しているサーバのドメインがそのまま対象になり、それで問題ないからだ。

むしろDomainを設定すると Cookie を送付する対象が広がってしまい、脆弱性が生まれる恐れすらある。
具体的には、Domain属性に指定したドメインだけでなく、そのサブドメインにも Cookie が送られるようになってしまう。

ローカル環境でsub.localhostのようなサブドメインを用意して動かしたが、思ったような挙動にならなかった。
だがこの機会にきちんと確認しておきたかったので、本番環境に動作確認用のページを用意して確認した。

具体的には、次の手順で確認する。

  1. ブラウザの開発者ツールを開き HTTP 通信の内容を確認できる状態にしておく
  2. https://numb86.net/ にアクセスする
    • そうすると、noSpecifyDomainCookiespecifyDomainCookieの 2 つの Cookie がセットされる。JavaScript でセットしているためレスポンスヘッダには含まれないので、注意する。
    • どちらもMax-Ageを 180 秒にしてあるので、ブラウザに残り続ける心配はない。逆に言えば 180 秒以内に以下の手順を行わないと、正しく検証できない。
    • noSpecifyDomainCookieにはDomain属性を設定せず、specifyDomainCookieにはDomain属性としてnumb86.netを設定している。
  3. ページをリロードして、リクエストヘッダの内容を確認する
    • CookieフィールドでnoSpecifyDomainCookiespecifyDomainCookieの両方を送信していれば、Cookie がブラウザに正しく保存されていることになる。
  4. サブドメインである https://ken-all.numb86.net/ にアクセスして、リクエストヘッダのCookieフィールドで何が送信されているか確認する
    • レスポンスはステータスコード404になるが、リクエストの内容には影響しないため問題ない
    • specifyDomainCookieは送信されているが、noSpecifyDomainCookieは送信されていない

つまり、Domainを指定した Cookie のみがサブドメインにも送信されていることを確認できた。

Cookie は Web Storage とは異なりオリジンではなくドメインで管理している。そのため、プロトコル(http or https)やポート番号が違っても、ドメインが同じなら Cookie は送信されるので注意する。

Path属性を指定すると、指定したパス以下の URL にアクセスした場合にのみ、Cookie が送信されるようになる。指定しなかった場合は/を指定したのと同じ扱いになり、どの URL であっても送信される。
以下のcという Cookie は、http://localhots:8080/foo/bar以下のパスにアクセスした場合にのみ、リクエストヘッダのCookieフィールドに含まれる。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'a=111; Max-Age=60; path=/',
    'b=222; Max-Age=60; path=/foo',
    'c=333; Max-Age=60; path=/foo/bar',
    'd=444; Max-Age=60;',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

Secure

Secure属性が設定された Cookie は、HTTPS 接続の場合にのみ、サーバに送られる。

この挙動を確認するため、自己署名証明書を用意してローカル環境に HTTPS サーバを立てる。
OpenSSLを使って秘密鍵(server.key)と証明書(server.crt)を作成した。OpenSSLの使い方はこの記事の範囲を越えるので割愛する。

そして、サーバのコードを以下のようにする。

const fs = require('fs');
const http = require('http');
const https = require('https');

const options = {
  key : fs.readFileSync('./server.key'),
  cert: fs.readFileSync('./server.crt')
};

https.createServer(options, (req, res) => {
  res.setHeader('Set-Cookie', [
    'secureCookie=111; Max-Age=60; Secure',
    'noSecureCookie=222; Max-Age=60',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8443);

http.createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);

このファイルを実行すると、https://localhost:8443http://localhost:8080の 2 つのサーバが起動する。

まずhttps://localhost:8443にアクセスする。すると、以下のレスポンスヘッダが返ってくる。

HTTP/1.1 200 OK
Set-Cookie: secureCookie=111; Max-Age=60; Secure
Set-Cookie: noSecureCookie=222; Max-Age=60

もう一度https://localhost:8443にアクセスすると、secureCookienoSecureCookieの両方がリクエストヘッダに含まれている。

GET / HTTP/1.1
Host: localhost:8443
Cookie: secureCookie=111; noSecureCookie=222

続いてhttp://localhost:8080にアクセスすると、noSecureCookieのみがリクエストヘッダに含まれている。

GET / HTTP/1.1
Host: localhost:8080
Cookie: noSecureCookie=222

HttpOnly

HttpOnly属性が設定された Cookie は、JavaScript からアクセスすることができなくなる。

以下のサーバではoperableWithJsnoOperableWithJsがレスポンスヘッダで渡されるが、JavaScript でアクセスできるのはHttpOnlyが設定されていないoperableWithJsのみである。

const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Set-Cookie', [
    'operableWithJs=111; Max-Age=60;',
    'noOperableWithJs=222; Max-Age=60; HttpOnly',
  ]);
  res.writeHead(200);
  res.end('Hello World');
}).listen(8080);
> document.cookie
"operableWithJs=111"

XSS という、ウェブアプリケーションをターゲットにした有名な攻撃方法があるが、これは攻撃者が用意した JavaScript コードをユーザーのブラウザで実行させる手口である。
XSS が成功してしまうと例えば、ユーザーのブラウザに保存されている Cookie が盗み出されてしまう。
Cookie にHttpOnly属性を設定しておくことで、少なくとも保存済みの Cookie を JavaScript によって盗まれてしまうことは防げる。

SameSite

SameSiteはまだ提案段階であり RFC で定義されておらず、今のところRFC 6265にも含まれていない。
だが各ブラウザベンダによる実装は着々と進んでおり、多くのブラウザで実際に使用できる。

参考:
Cookieの仕様改定版、RFC6265bisの議論 - ASnoKaze blog
Can I use... Support tables for HTML5, CSS3, etc

セキュリティ的にかなり重要な機能なので、SameSite属性については別途記事を書いた。

numb86-tech.hatenablog.com