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

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

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