SameSite指定されたCookieはCORS fetch時にどう働くか

まとめ

2020/05/17 修正

fetch などを使った CORS リクエストにおいて、API サーバから SameSite 設定付きで Set-Cookie が返された場合、以降の CORS リクエストに Cookie は付くのかどうか → SameSite=none の場合のみ Cookie が付く
ただし、サブドメイン部だけが異なるドメイン間での CORS の場合、lax/strict でも Cookie が付くもうちょっと調べたトピック 参照


以下の通り、lax や strict を指定された Cookieは 別ドメインに対する CORS fetch リクエストには付かない。

SameSite Cookie
none ○付く
lax ×付かない
strict ×付かない

CORS で Cookie を使う場合、 SameSite=none にしつつ API の応答に Access-Control-Allow-Credentials: true を付けたり、 fetch 時のオプションに credentials: 'include' 付けたりと、CORSの仕様に沿う必要もある。

また、ブラウザの設定でサードパーティーCookieがブロックされている場合、 SameSite の指定に関係なく Cookie は付かない(Set-Cookieが無視されるのでブラウザに記憶されない)。


この挙動を考えると、Cookie認証なAPI サーバを別ドメインにする構成では SameSite=none の指定が必須になるわけですね。

サードパーティーCookieが拒否される風潮を鑑みると、次のような対応にしたほうがいいのやも。

  • リバースプロキシなどを使い、表向きは同一ドメインでのAPIアクセスにして裏で振り分ける構成にする
  • Cookie の代わりに JWT などの Token で認証するようにする(XSS攻撃された場合のToken漏洩問題が出てきますが…)

以下打ち消し線部分は間違い

Cookie の挙動と Same Origin Policy を混同してました。
Cookie の場合、ドメイン名のみで識別されスキーマとポート番号は無視されるので、APIサーバを別ポートで動かしただけでは CORS が必要になるけれども Cookie の挙動は同一ドメイン時と同じ、という状態でのテストになっていた。という次第。
(そりゃ全部Cookie付くわなorz)
fetch などを使った CORS リクエストにおいて、API サーバから SameSite 設定付きで Set-Cookie が返された場合、以降の CORS リクエストに Cookie は付くのかどうか → 付く。

SameSite Cookie
none
lax
strict

ただし、API サーバの応答に Access-Control-Allow-Credentials: true を付けたり、 fetch 時のオプションに credentials: 'include' 付けたりと、CORS 下で Cookie を使う仕様に沿う必要はある。

Cookieを使ったAPIの認証をする場合は、 httponly + samesite としておけばよさそう。

検証に使った適当なコード

簡易API サーバ

指定された条件で Set-Cookie を発行する API と、リクエスト中の Cookie を取得して返す API の簡易APIサーバ。
ドメインに見せかけるため、 hosts ファイルに以下を追加して apitest.example.com でアクセスする(検証が終わったら削除する)。

127.0.0.1       apitest.example.com

/api/ に { 'msg': 'hoge', 'samesite': 0} みたいな JSON を POST すると Set-Cookie 入りのレスポンスを返す。
/api/cookie/ に POST したり GET したりすると、リクエスト中の Cookie の中身を JSON で返す。

// cors_api.js
const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors');

const app = express();
app.use(cookieParser());
app.use(express.json()); // application/json

// see https://expressjs.com/en/resources/middleware/cors.html#configuration-options
const cors_options = {
    origin: true,       // Access-Control-Allow-Origin: <Origin>
    credentials: true   // Access-Control-Allow-Credentials: true
}

// set cookie to response
app.options('/api/', cors(cors_options));
app.post('/api/', cors(cors_options), (req, res) => {
    console.log(req.cookies);
    const msg = req.body.msg;
    const samesite = req.body.samesite == 0 ? 'none' : req.body.samesite == 1 ? 'lax' : 'strict';

    res.cookie('msg', msg, {
        httpOnly: true,
        sameSite: samesite
    });
    res.json({ msg : `msg=${msg}; samesite=${samesite}`});
});

// get cookie from request
const getCookie = (req, res) => {
    console.log(req.cookies);

    const msg = req.cookies['msg'];

    res.json({ msg : `msg=${msg}`});
};
app.options('/api/cookie/', cors(cors_options));
app.get('/api/cookie/',  cors(cors_options), getCookie);
app.post('/api/cookie/', cors(cors_options), getCookie);

// start server
app.listen(3000, 'localhost', () => console.log('Listening on port 3000'));
ドライバHTML

簡易APIサーバの API を叩くドライバHTMLを、VS Code の LiveServerなどの適当なWebサーバ介して localhost ドメインとしてブラウザで開く。
テキストボックスに Cookie に設定する文字列を入れ、その後ろのセレクトボックスは Cookie に設定する SameSite の種類を選ぶ。SET ボタンを押すと /api/ が叩かれて Set-Cookie 付きのレスポンスが得られる。
そして、GETボタンやPOSTボタンを押すと、それぞれのメソッドで /api/cookie/ が叩かれて、それらのリクエストに付いてくる Cookie の中身が表示される。
msg=undefined となったり、テキストボックスに入れた文字列と違うものが表示された場合はサーバ側で Cookie が取得できていないことを表す。

<!DOCTYPE html>
<html>
  <body>
    <input type="text" id="msg"></input>
    <select id="samesite">
      <option value="0">none</option>
      <option value="1">lax</option>
      <option value="2">strict</option>
    </select>
    <button id="s">SET</button>
    <button id="s_get">GET</button><button id="s_post">POST</button><span id="ans"></span>

    <script>
        document.getElementById('s').addEventListener('click', async (e) => {
            e.preventDefault();
            const msg = document.getElementById('msg').value;
            const samesite = document.getElementById('samesite').value * 1;
            const req = { msg, samesite };
            res = await fetch("http://apitest.example.com:3000/api/",
                { method: 'POST',
                  body: JSON.stringify(req),
                  mode: "cors",
                  credentials: 'include',
                  headers: {
                      "Content-Type": "application/json; charset=utf-8",
                  }
                });
            json = await res.json();
            document.getElementById('ans').innerText = json.msg;
        });
        document.getElementById('s_get').addEventListener('click', async (e) => {
            e.preventDefault();
            res = await fetch("http://apitest.example.com:3000/api/cookie/",
                { method: 'GET',
                  mode: "cors",
                  credentials: 'include'});
            json = await res.json();
            document.getElementById('ans').innerText = json.msg;
        });
        document.getElementById('s_post').addEventListener('click', async (e) => {
            e.preventDefault();
            res = await fetch("http://apitest.example.com:3000/api/cookie/",
                { method: 'POST',
                  mode: "cors",
                  credentials: 'include'});
            json = await res.json();
            document.getElementById('ans').innerText = json.msg;
        });
    </script>
  </body>
</html>