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",
  });
});