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=xxx
とb=yyy
になっている。
Cookie は名前=値
という形式で指定するので、これでブラウザにはa
とb
の 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.cookie
がb=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 6265
の5.2
に書かれてある 6 つの属性について見ていく。
属性も、保存するデータと同じように属性名=値
という形式で設定する。
まず、Expires
とMax-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 はリクエストに自動的に付与されるが、その挙動を制御するための属性がDomain
とPath
。
Domain
はその名の通り、Cookie を送るドメインを指定する。
だが現実的には、この属性を設定することはほとんどないはず。
設定しない場合、Cookie を発行しているサーバのドメインがそのまま対象になり、それで問題ないからだ。
むしろDomain
を設定すると Cookie を送付する対象が広がってしまい、脆弱性が生まれる恐れすらある。
具体的には、Domain
属性に指定したドメインだけでなく、そのサブドメインにも Cookie が送られるようになってしまう。
ローカル環境でsub.localhost
のようなサブドメインを用意して動かしたが、思ったような挙動にならなかった。
だがこの機会にきちんと確認しておきたかったので、本番環境に動作確認用のページを用意して確認した。
具体的には、次の手順で確認する。
- ブラウザの開発者ツールを開き HTTP 通信の内容を確認できる状態にしておく
- https://numb86.net/ にアクセスする
- そうすると、
noSpecifyDomainCookie
とspecifyDomainCookie
の 2 つの Cookie がセットされる。JavaScript でセットしているためレスポンスヘッダには含まれないので、注意する。 - どちらも
Max-Age
を 180 秒にしてあるので、ブラウザに残り続ける心配はない。逆に言えば 180 秒以内に以下の手順を行わないと、正しく検証できない。 noSpecifyDomainCookie
にはDomain
属性を設定せず、specifyDomainCookie
にはDomain
属性としてnumb86.net
を設定している。
- そうすると、
- ページをリロードして、リクエストヘッダの内容を確認する
Cookie
フィールドでnoSpecifyDomainCookie
とspecifyDomainCookie
の両方を送信していれば、Cookie がブラウザに正しく保存されていることになる。
- サブドメインである 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:8443
とhttp://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
にアクセスすると、secureCookie
とnoSecureCookie
の両方がリクエストヘッダに含まれている。
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 からアクセスすることができなくなる。
以下のサーバではoperableWithJs
とnoOperableWithJs
がレスポンスヘッダで渡されるが、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
属性については別途記事を書く予定。