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

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

Cloudflare CDN での Cookie の取り扱いと注意点

CDN のエッジサーバにキャッシュされたコンテンツは、最初にアクセスしたクライアント以外にも利用される。
例えば、キャッシュが存在しない状態で A というクライアントがhttps://example.com/someにアクセスした場合、オリジンサーバがコンテンツを返す。その際、エッジサーバがそのコンテンツをキャッシュしたとする。そうすると、次に A がこの URL にアクセスした場合はもちろん、A 以外がこのエッジサーバ経由でアクセスした場合も、オリジンサーバへの問い合わせは行われず、エッジサーバがキャッシュを返すようになる。
そのため、レスポンスの高速化や、オリジンサーバの負荷軽減が見込める。

だがそれは、エッジサーバにキャッシュしたコンテンツは不特定多数のクライアントに閲覧される可能性がある、ということを意味する。
アクセス制限がないコンテンツならそれで構わないのだが、アクセス制限のあるコンテンツの場合、それを公開状態にしてしまっているのだから、問題がある。
公開されてしまったコンテンツの内容によって被害の大きさは変わってくるが、個人情報や機密情報が含まれていた場合、重大な事故と言える。
このような事態を防ぐため、何をエッジサーバにキャッシュさせるのかについて慎重に判断し設定を行う必要がある。

Cloudflare CDN における Cookie の取り扱い

扱いに気をつけなければいけない情報のひとつが、Cookie。Cookie には様々な用途があるが、セッションIDなどが含まれていることもあるため、慎重に扱わないといけない。

Cookie は、レスポンスヘッダのSet-Cookieフィールドを使ってクライアントに渡される。そのため、Set-Cookieフィールドを含むレスポンスをエッジサーバにキャッシュしてしまうと、不特定多数のクライアントがその Cookie を受け取ってしまう。

Cloudflare では、Set-Cookieフィールドがエッジサーバにキャッシュされてしまうことは、基本的にはない。
リクエストメソッドは GET、ステータスコードは200のケースで検証したが、キャッシュされてしまうケースは確認できなかった。後述するが、リクエストメソッドやステータスコードによって細かい挙動が変わることはある。

具体的には、通常はエッジサーバにキャッシュされるようなケースでも、Set-Cookieフィールドが存在する場合はキャッシュされなくなる。Cache-Controls-maxagepublicを指定しても、キャッシュされない。

Cloudflare では、以下のいずれかの設定を行うと、エッジサーバへのキャッシュが必ず行われるようになる。

  • 「エッジ キャッシュ TTL」を設定し「キャッシュ レベル」を「Cache Everything」に設定する
    • 全てのコンテンツが必ずエッジサーバにキャッシュされる
  • 「エッジ キャッシュ TTL」を設定し「キャッシュ レベル」を「標準」に設定する
    • デフォルトでキャッシュ対象のコンテンツが必ずエッジサーバにキャッシュされる

詳細は以下を参照。
Cloudflare を使ったキャッシュの基礎 - 30歳からのプログラミング

この場合、オリジンサーバからのレスポンスにSet-Cookieフィールドが存在してもキャッシュされるようになるのだが、Set-Cookieフィールドを削除した上でキャッシュするようになる。
そのため、Cookie の流出という状況は発生しない。
ただ、エッジサーバでSet-Cookieフィールドを削除してしまっているので、本来 Cookie を受け取るべきクライアントも受け取れなくなってしまうので、注意する。

Cookie を使って生成されたコンテンツがキャッシュされる可能性はある

このように、Set-Cookieフィールドが存在する場合、キャッシュ自体を行わないか、Set-Cookieフィールドを削除した上でキャッシュを行うため、エッジサーバに Cookie がキャッシュされてそれが流失してしまう、という事故は防げる。
しかしこれで安心というわけではない。なぜなら、Cookie そのものは流出しなくても、Cookie によって生成されたコンテンツが流出してしまうという可能性は残っているため。

具体例を示すため、以下のようなサイトを作り、Cloudflare 経由で配信する。
コードはこの記事の最後に載せておく。

  • /loginにアクセスするとログインフォームが表示される
  • /mypageにアクセスすると、ログインしているユーザーの住所が表示される
  • ログインしていない状態で/mypageにアクセスすると、「アクセス権限がありません」と表示される
  • ログイン状態は Cookie に保存したセッションIDで判断する

ログインフォームで正しい情報を入力すると、Cookie を使ってセッションIDが渡される。
セッションIDがあれば、それを使ってマイページを表示する。
ログアウトすると Cookie が削除されるため、マイページを表示できなくなる。

f:id:numb_86:20210618011802g:plain

次に、Cloudflare の設定を変えて、全ての URL に対して「エッジ キャッシュ TTL」を設定し、かつ「キャッシュ レベル」を「Cache Everything」にする。

この状態だと全てのコンテンツがエッジサーバにキャッシュされると書いたが、実はこれには例外があり、リクエストメソッドやステータスコードによっては、キャッシュされないことがある。
今回作ったサイトでは、ログインは/loginへの POST メソッドで行っており、ログインに成功すると303ステータスコードを返すようにしている。そしてこのレスポンスで、Set-Cookieフィールドを付与してセッションIDを渡している。その場合、cf-cache-statusは DYNAMIC になっており、エッジサーバへのキャッシュは行われていない。
そのためこの設定においても、このサイトの Cookie は流出していない。

問題は、/mypageである。このページへのリクエストは GET メソッドで行い、ログイン状態の場合はステータスコードは200で返ってくる。そのため、エッジサーバにキャッシュされてしまう。
つまり、ログインしておらず Cookie がない状態でも、このページが見れてしまう。ユーザーfooのマイページがキャッシュされた場合、fooの住所を誰でも見れてしまうことになる。

f:id:numb_86:20210618011930g:plain

また、foo以外のユーザー(今回の例ではbar)でログインしてもfooのマイページが表示されるという問題も発生する。

f:id:numb_86:20210618012012g:plain

このような状況を防ぐためには、Cookie そのものの扱いだけでなく、Cookie などから生成されるコンテンツのキャッシュにも気をつける必要がある。
今回のケースでは、センシティブな情報を含むコンテンツを扱っているのだから、サイト全体に対して「エッジ キャッシュ TTL」と「Cache Everything」を設定すべきではなかった。

このように、CDN は非常に便利ではあるが、何をキャッシュして何をキャッシュしないのか慎重に検討しないと、事故の原因になる可能性がある。

検証に使ったコード

以下のコードをオリジンサーバで動かし、Cloudflare のエッジサーバを経由してアクセスされるようにした。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.99.0/http/mod.ts";
import { parse } from "https://deno.land/std@0.99.0/flags/mod.ts";
import { getCookies } from "https://deno.land/std@0.99.0/http/cookie.ts";
import { readAll } from "https://deno.land/std@0.99.0/io/util.ts";

type User = {
  name: string;
  password: string;
  sessionId: string;
  address: string;
};

const topHtml = `<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  <p>
    <a href="/mypage">マイページ</a>
  </p>
  <p>
    <a href="/login">ログインフォーム</a>
  </p>
</body>
</html>
`;

const loginHtml = `<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  <p>
    ログインしてください
  </p>
  <form method="POST" action="/login">
    <label for="name">name: </label><input type="text" name="name" id="name"><br>
    <label for="password">password: </label><input type="password" name="password" id="password"><br>
    <p><input type="submit" value="ログイン"></p>
  </form>
  <p>
    <a href="/">トップページに戻る</a>
  </p>
</body>
</html>
`;

const failHtml = `<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  <p>
    ログインに失敗しました
  </p>
  <p>
    <a href="/login">再試行する</a>
  </p>
  <p>
    <a href="/">トップページに戻る</a>
  </p>
</body>
</html>
`;

const forbiddenHtml = `<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  <p>
    アクセス権限がありません
  </p>
  <p>
    <a href="/login">ログインする</a>
  </p>
  <p>
    <a href="/">トップページに戻る</a>
  </p>
</body>
</html>
`;

function returnMyPage(user: User) {
  return `<html lang="ja">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <p>
      ログインしています
    </p>
    <p>
      name: ${user.name}<br>
      address: ${user.address}<br>
    </p>
    <p>
      <a href="/">トップページに戻る</a>
    </p>
    <form method="POST" action="/logout">
      <p><input type="submit" value="ログアウト"></p>
    </form>
  </body>
  </html>
`;
}

function parseRequestBody(requestBody: string): Record<string, string> {
  const array = requestBody.split("&");
  return array.reduce((acc, item) => {
    const [key, value] = item.split("=");
    return {
      ...acc,
      [key]: value,
    };
  }, {});
}

const users: User[] = [
  {
    name: "foo",
    password: "abc",
    sessionId: "MsFARSgGhr70nO25M6ZU",
    address: "東京都 港区 X-X-X",
  },
  {
    name: "bar",
    password: "xyz",
    sessionId: "SwVfYHU2uq6a2paZZ2vn",
    address: "沖縄県 那覇市 X-X-X",
  },
];

function getSessionId(name: string, password: string) {
  const user = users.find((elem) => elem.name === name);
  if (!user) return null;
  return user.password === password ? user.sessionId : null;
}

const argPort = parse(Deno.args).port;
const port = argPort ? Number(argPort) : 8080;

listenAndServe({ port }, async (req: ServerRequest) => {
  if (req.url === "/") {
    req.respond({
      status: 200,
      headers: new Headers({
        "Content-Type": "text/html",
      }),
      body: topHtml,
    });
    return;
  }

  if (req.url === "/login") {
    const { method } = req;
    if (method === "GET") {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: loginHtml,
      });
    } else if (method === "POST") {
      const decoder = new TextDecoder("utf-8");
      const body = await readAll(req.body);
      const { name, password } = parseRequestBody(decoder.decode(body));
      const sessionId = getSessionId(name, password);

      if (sessionId) {
        req.respond({
          status: 303,
          headers: new Headers({
            "Content-Type": "text/html",
            "Set-Cookie": `sessionId=${sessionId}; max-age=30`,
            Location: "/mypage",
          }),
        });
      } else {
        req.respond({
          status: 401,
          headers: new Headers({
            "Content-Type": "text/html",
          }),
          body: failHtml,
        });
      }
    } else {
      req.respond({
        status: 405,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Method Not Allowed\n",
      });
    }
    return;
  }

  if (req.url === "/mypage") {
    const sessionId: string | undefined = getCookies(req).sessionId;
    const user = users.find((elem) => elem.sessionId === sessionId);

    if (user) {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: returnMyPage(user),
      });
    } else {
      req.respond({
        status: 403,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: forbiddenHtml,
      });
    }
    return;
  }

  if (req.url === "/logout") {
    req.respond({
      status: 303,
      headers: new Headers({
        "Content-Type": "text/html",
        "Set-Cookie": "sessionId=; max-age=-1",
        Location: "/login",
      }),
    });
    return;
  }

  if (req.url === "/favicon.ico") {
    const ico = await Deno.readFile("./images/favicon.ico");
    req.respond({
      status: 200,
      headers: new Headers({
        "Content-Type": "image/vnd.microsoft.icon",
      }),
      body: ico,
    });
    return;
  }

  req.respond({
    status: 404,
    headers: new Headers({
      "Content-Type": "text/plain",
    }),
    body: "Not Found\n",
  });
});

有効期限切れのキャッシュをそのまま再利用させないための must-revalidate ディレクティブ

レスポンスヘッダのCache-Controlフィールドに設定できるディレクティブとして、must-revalidateがある。
これは、有効期限が切れたキャッシュをそのまま再利用することを許可せず、必ずオリジンサーバに問い合わせることを指示するディレクティブである。
逆に言うと、must-revalidateがない場合、既に有効期限が切れているキャッシュが再検証なしに使われてしまう可能性がある。

この記事では、must-revalidateの有無で動作がどのように変わるのか、CDN として Cloudflare を利用した構成を使って確認していく。
API の返り値をキャッシュしたいので、「キャッシュ レベル」を「Cache Everything」にしている。
また、クライアントのキャッシュが再利用されないように、シークレットウィンドウを使って動作確認している。

結論を先に書くと、must-revalidateを指定していない場合、オリジンサーバが 500 番台のエラーを返したときに古いキャッシュが使われてしまうことがある。

それを検証するためにまず、特定のヘッダフィールドがあった場合に503を返す API を作る。
Deno で以下のようなサーバを書いた。

    case /^\/api\/score$/.test(req.url): {
      const isError = req.headers.get("x-error") === "on";
      const status = isError ? 503 : 200;
      const body = JSON.stringify(
        isError ? { message: "error" } : { score: 90 }
      );
      req.respond({
        status,
        headers: new Headers({
          "Content-Type": "application/json",
          "Cache-Control": "max-age=30",
        }),
        body,
      });
      break;
    }

/api/scoreへのリクエストでヘッダにx-error: onがある場合は503になる。そうでない場合はリクエストが成功しスコアを得る。
また、max-age=30としたため、30秒間はキャッシュが利用される。

あとは、x-error: onがあるリクエストとないリクエストをそれぞれクライアントから送信できるようにすればよい。
今回はそれぞれのボタンを用意し、それを押下するとfetch/api/scoreにリクエストを送信する仕組みにした。
以降、リクエストに成功するボタンを200ボタン、503が返ってくるボタンを503ボタンと呼ぶことにする。

この内容でオリジンサーバにデプロイし、サイトにアクセスしてみる。
CDN として Cloudflare を利用しているため、以下の流れでリクエストが送られる。

クライアント -> Cloudflare のエッジサーバ -> オリジンサーバ

まず200ボタンを押下する。そうするとエッジサーバがオリジンサーバにリクエストを送り、レスポンスを受け取る。
エッジサーバはそれをクライアントに返すのだが、その際、自身にキャッシュを行う。そのため、次にリクエストを受け取ったときはそのキャッシュをクライアントに返すようになる。

30秒以内に改めてシークレットウィンドウでサイトを開き、再び200ボタンを押下する。
そうすると、エッジサーバにキャッシュされたコンテンツが返ってくる。
オリジンサーバにリクエストを送らずエッジサーバで返しているため、レスポンスが高速になる。

上がオリジンサーバに問い合わせたケースで、下がエッジサーバがキャッシュを返したケース。スピードに大きな違いが出ている。

f:id:numb_86:20210615020239p:plain

レスポンスヘッダを見るとcf-cache-statusが HIT になっている。これは、エッジサーバでキャッシュが見つかりそれを返したことを意味する。
age: 8は、エッジサーバにキャッシュされてから8秒経過したことを意味している。

f:id:numb_86:20210615020311p:plain

次に、30秒が経過してから改めてシークレットウィンドウを開き直し、200ボタンを押下する。
そうすると、既にキャッシュの有効期限が切れているため再度オリジンサーバに問い合わせることになる。レスポンスヘッダを確認するとcf-cache-statusは EXPIRED になっている。
そして再びエッジサーバにコンテンツがキャッシュされ、30秒間キャッシュが有効になる。

同様のやり方で有効期限(30秒)内に503ボタンを押下すると、やはりエッジサーバのキャッシュがヒットし、それが返ってくる。そのためステータスコードは200になる。
これは意図した挙動であり、問題ない。/api/scoreCache-Controlmax-age=30にしているのだから、その URL にアクセスすると30秒間は、キャッシュされた内容が返ってくる。

問題は、30秒以上経過してから503ボタンを押下したときである。
そうすると、ステータスコードは200になり、期限が切れたはずのコンテンツが返ってきてしまう。

レスポンスヘッダを確認すると、age30以上になっており、有効期限は間違いなく切れている。そしてcf-cache-statusは STALE であり、有効期限切れのコンテンツを使っていることが分かる。

f:id:numb_86:20210615020337p:plain

なお、キャッシュを使っているとはいえオリジンサーバへの問い合わせは発生しているため、速度は遅い。

f:id:numb_86:20210615020348p:plain

このように、有効期限が切れたキャッシュも、状況によってはそのまま再利用されてしまうのである。
有効期限は一種のフラグと言うか、キャッシュの状態を示すものである。有効期限が切れるとキャッシュの状態が変化するが、それは、キャッシュが削除されることを意味しない。有効期限が切れてもキャッシュが残り続ける可能性がある。
逆に、有効期限以内であっても、キャッシュが削除されてしまうこともあり得る。クライアントやエッジサーバの記憶領域は有限であるため、そのようなことが起こりうる。
キャッシュの存在の有無と有効期限は、それぞれ別の概念なのである。
そしてキャッシュが存在する場合、有効期限が切れていたとしても使われる可能性があるのは、ここまで見てきた通りである。

これを防ぐためのディレクティブが、must-revalidateである。
/api/scoreのレスポンスヘッダを以下のように書き換えて、オリジンサーバにデプロイする。

headers: new Headers({
  "Content-Type": "application/json",
  "Cache-Control": "max-age=30, must-revalidate",
}),

must-revalidateは、有効期限が切れた際にオリジンサーバへの問い合わせを強制するディレクティブ。
そのため、Cache-Controlmax-age=30, must-revalidateにすると、30秒間はキャッシュを使いそれを経過したら必ずオリジンサーバに問い合わせるようになる。

早速、確認する。
Cloudflare のダッシュボードでキャッシュをパージしたあと、200ボタンを押下する。
そしてサイトを開き直して503ボタンを押下する。
有効期限内なら先程と同様にキャッシュされたコンテンツが返ってくるが、有効期限が切れている場合はキャッシュは再利用されず503が返ってくる。
cf-cache-statusも EXPIRED になっている。

f:id:numb_86:20210615020401p:plain

参考資料