弱点補強 — HTTP キャッシュと CDN:他人のページが返る × 鍵の設計 × 期限切れの殺到 × 価格切替をまたぐ古い表示

2026-10-04 日曜(Day 143) 日曜弱点補強 ★★★★★ RFC 9111 §3.5 / proxy_cache_lock / stale-while-revalidate nginx 1.31.6 で実測 / Cloud CDN 対応表

概要

🔑

鍵に無い入力で中身が変わる応答は、必ず他人に返る

Bad は 1,000 訪問のうち 344〜345 人が他人のページと他人のセッション Cookie を受け取った。Good は 0。

🧱

安全性を前段の挙動に預けない

nginx は RFC 9111 §3.5(Authorization 付きは保存しない)を守らず、988〜998 人が他人のクーポンを受け取った。Cloud CDN は守る。

⏱️

寿命は価格の切替時刻で切る

Bad は終了後の要求の 100% に旧価格。Good は 0 件。ただし余裕 0 秒では 21 件(nginx の時計が秒単位)→ 1 秒手前で切った。

🧪

一層が効くと、もう一層の故障が見えない

origin の既定を public に壊しても nginx の許可リストに守られて緑のまま。origin を直接見るテストを足して赤に。

数値はすべて手元の nginx 1.31.6+Python origin での実測(3回の範囲)。会員・価格・問い合わせ件数は架空。Cloud CDN では測っていない。

問題

MOps 販促システムチームは、10月1日 12:00 の秋セール開始に合わせてアプリ会員に push 通知を送る。過去の同規模の配信では通知直後に LP(/campaign/autumn)へ 420 req/s が集中し、origin の p99 が 2 秒を超えた。その対策として origin の前にキャッシュ層を入れた(本番は Cloud CDN、受け入れ検証は nginx proxy_cache)。

LP は会員ごとに中身が違う(「こんにちは ○○ さん・保有ポイント・あなた専用クーポンコード」)。セール価格 7,980 円は 10月3日 23:59:59 で終わり、以後は通常価格 9,980 円。

観測(本問の前提データ。数値は架空)値出どころ
「他人の名前とクーポンが表示された」問い合わせ12:00 の配信から 38 分で 27 件CS
他人のクーポンコードの利用19 件注文 DB
「急に別の人のアカウントでログインしていた」4 件CS
ステージングの受け入れテスト全件緑(push リンクから 200 人分を開いて確認済み)QA
キャッシュヒット率(LP)想定 90% 以上 → 実際 30% 台CDN のログ
「表示とカートの価格が違う」10月4日 00:00〜00:05 に 11 件CS

push 通知のリンクは /campaign/autumn?utm_source=push&utm_campaign=autumn2026&utm_content=<会員ID>(会員ごとに違う)。

後輩A「キャッシュの鍵に $cookie_sid を足せば会員ごとに別々にキャッシュされるので漏れません。1行で直ります」
後輩B「個人情報が絡むページにキャッシュは怖いので、LP は全部 no-store にして Pod を3倍に」

設問

  1. Bad の問題点を 7 つ以上(何が誰に起きるか、関係する仕様)
  2. ステージングの受け入れテストが全件緑だった理由
  3. 後輩A・B の提案を測って評価
  4. 改善後の origin コードとキャッシュ層の設定(価格切替をまたいで旧価格を出さない)
  5. Bad に流すと赤になるテスト(本物のキャッシュ層を挟んで)
  6. 監視・切替条件・既に起きた漏えいへの対応(法令上の報告の要否を含む)

制約・前提条件

  • origin は Python 3.12+。キャッシュ層は本番 Cloud CDN、検証は nginx。どちらか一方の挙動に依存した安全性は避ける
  • 会員ごとの部分(名前・ポイント・クーポン)は消せない(「あなた専用」表示が CVR を上げると分かっている)。utm_* も計測チームが使うので消せない
  • セール価格は終了時刻を1秒でも過ぎたら出さない(表示とカートの食い違いは問い合わせと、景品表示法の有利誤認の疑いにつながる)
  • CDN の invalidation は反映に数分かかり回数制限もあるため、通常運用の前提にしない

Bad → Good(対比)

origin とキャッシュ層に 8つの問題。左が Bad、右が Good の要点。
Bad: origin_bad.py + nginx_bad.conf
# ミドルウェア: 全 GET にキャッシュヘッダ
if path.startswith("/api/"):
    headers["Cache-Control"] = "max-age=60"   # Authorization 付きは保存されないはず
else:
    headers["Cache-Control"] = f"public, max-age={LP_TTL}"  # 300
    headers["Vary"] = "User-Agent"

# LP: Cookie で会員を引いて名前・クーポンを埋め込む
greeting = f"こんにちは {member['name']} さん ... {member['coupon_code']}"
price = SALE_PRICE_YEN if time.time() < SALE_END else REGULAR_PRICE_YEN
headers["Set-Cookie"] = f"sid={member['sid']}; ..."

# nginx
proxy_cache_key $scheme$host$request_uri;
proxy_ignore_headers Set-Cookie;
# proxy_cache_lock 未設定(既定 off)
Good: origin_good.py + nginx_good.conf
headers.setdefault("Cache-Control", "private, no-store")

# LP の外枠: Cookie・クエリ・UA を読まない
'<div id="me" data-src="/api/me/summary"></div>'
policy = SharedCachePolicy.until_next_change(now, next_price_change(now))
"public, max-age=0, s-maxage=≤60, stale-while-revalidate=≤30, stale-if-error=≤600"

# /api/me/*: private, no-store(既定のまま)。Set-Cookie はここだけ

# nginx
location / { proxy_pass http://origin; }
location /campaign/ {
  proxy_cache_key $scheme$host$uri;
  proxy_cache_lock on; proxy_cache_background_update on;
  proxy_no_cache $http_authorization;
}

設計の図(SVG)

Bad: 鍵は URL だけ、中身は Cookie で変わる → 2 人目に 1 人目の応答 Alice(sid=A)1 人目・直接訪問 Bob(sid=B)2 人目・同じ URL nginx / CDN鍵 = /campaign/autumn(Cookie なし)保存: Alice の HTML + Set-Cookie: sid=Aproxy_ignore_headers Set-Cookie originpublic, max-age=300Vary: User-Agent「こんにちは Alice さん」 MISS HIT → Bob に Alice の名前・クーポン・sid=A 実測: 344〜345 / 1,000 人。API は 988〜998 人 Good: 「誰が見ても同じ外枠」と「その人だけ」を URL で分ける。既定は保存しない ブラウザ外枠 → 会員 API /campaign/ — 保存する鍵 = $uri(utm を外す)、lock onヒット率 99.9%、殺到時 origin 1 本 /api/me/* — 保存しない(既定)private, no-store + Set-Cookie はここだけ漏えい 0 件 originSharedCachePolicys-maxage = min(60, 切替まで − 1 秒)stale も切替時刻の内側にmax-age=0(ブラウザに残さない)終了後の旧価格 0 件 期限切れ時に 1 本 毎回(3 ms) origin の処理時間(1,000 訪問): Bad 22.4〜23.6 秒 → Good 7.8〜8.6 秒。描画 30 ms を 1 回に、残りは軽い会員 API(3 ms)。

鍵(何が同じなら同じ応答とみなすか)と、中身を変える入力の一覧を一致させるのが設計の中心。Bad は「Cookie が鍵に無いのに中身を変える」(粗すぎ)と「utm が鍵にあるのに中身を変えない」(細かすぎ)の両方を持っていた。

ヒント(段階的開示)

ヒント1(方向性)

キャッシュの鍵に入っていないのに応答の中身を変えている入力を全部探す。Cookie、Authorization、User-Agent、クエリ、そして時刻。鍵に無い入力で中身が変わるなら、その応答は他の人に返る。逆に、鍵に入っているのに中身を変えない入力は、ヒット率を下げるだけ。

ヒント2(アプローチ)
  • public は「共有キャッシュに保存してよい」という origin の宣言。RFC 9111 §3.5 は Authorization 付きの要求への応答の保存を原則禁じるが、nginx はこれを実装していない(測れば分かる)
  • proxy_ignore_headers Set-Cookie は「Set-Cookie を見ずに保存する」。保存された Set-Cookie は次の人にも返る
  • 会員ごとの部分を共有される応答の外に出す(別 URL・private, no-store)と、残った外枠は誰に返しても同じ
  • 期限切れの瞬間の同時要求を origin に何本通すか(proxy_cache_lock、Cloud CDN の request coalescing)
  • 価格の切替時刻が分かっているなら、寿命(stale を含む)をその時刻で切る
ヒント3(誘導)
class SharedCachePolicy:              # 共有キャッシュに保存してよい応答の寿命(値オブジェクト)
    s_maxage: int
    stale_while_revalidate: int
    stale_if_error: int

    @classmethod
    def until_next_change(cls, now, next_change):
        remaining = floor(next_change - now) - 時計の余裕   # ← nginx の時計は秒単位
        ttl = min(基準 TTL, remaining)
        room = remaining - ttl                               # stale もこの中に収める
        ...

def _send(...):
    headers.setdefault("Cache-Control", "private, no-store")  # 既定は保存させない

問題点分析(8点)

#箇所何が誰に起きるか(実測)仕様・挙動
1会員ごとの LP に public, max-age=300次の会員に前の会員のページ。344〜345 / 1,000 人(見られた側 51 人)RFC 9111 §5.2.2.9 public。鍵 $request_uri に Cookie なし
2proxy_ignore_headers Set-Cookie他人のセッション Cookie が届きアカウント乗っ取り。344〜345 人nginx は既定で Set-Cookie 付きを保存しない。その安全装置を外した
3API に max-age=60(「Authorization 付きは保存されない」と思い込み)1 人目のクーポン一覧が全員に。988〜998 / 1,000 人、origin に届いたのは 2 本§3.5 は保存禁止だが nginx は未実装。Cloud CDN は守る=前段で安全性が変わる
4鍵に会員ごとの utm_contentpush 経由 60% が全部ミス。LP ヒット率 34.4〜34.5%鍵には中身を変える入力だけ。Cloud CDN は includedQueryParameters
5Vary: User-AgentUA の種類数だけ版が割れる(50 種類で測定。実際は数千)Cloud CDN は許可外の Vary だと保存しない
6proxy_cache_lock 未設定期限切れ直後の同時 200 本のうち 73〜200 本が origin へnginx 既定 off。Cloud CDN は coalescing 既定 on
7寿命 300 秒が価格切替を考えないセール終了後の要求の 100%(532〜559 件)に旧価格。窓を延ばせば最大 300 秒invalidation は数分かかる
8ブラウザにも 300 秒、障害時 stale の方針なし共用端末でログアウト後も前の人のページ。origin 停止中は 502max-age はブラウザも従う。s-maxage は共有キャッシュだけ

受け入れテストが緑だった理由

QA は push リンクから 200 人分を開いた。push リンクは会員ごとに違う utm_content を持つので鍵が全員違い、全員ミス。一度もヒットしないから漏れもしなかった。

実装訪問LP の漏えい他人の CookieLP ヒット率API の漏えい
Bad・S1b(全員 push 経由=受け入れテスト)200000.0%198〜199
Bad・S1(本番の朝。直接訪問 40%)1,000344〜345344〜34534.4〜34.5%988〜998
問題 #4(鍵が細かすぎる)が問題 #1・#2(鍵が粗すぎる)を隠していた。漏えいの件数はヒットの件数と一致する。受け入れテストに要るのは「同じ URL を、別の会員が 2 回目に開く」手順。API の漏えいは出ていたが、確認項目が LP の表示だけだった。

後輩の提案の評価(実測)

実装LP 漏えい他人の CookieAPI 漏えいLP ヒット率origin 要求origin 処理時間
Bad344〜345344〜345988〜99834.4〜34.5%658〜66722.4〜23.6 秒
後輩A(鍵に $cookie_sid)00994〜9990.0%1,001〜1,00632.7〜32.8 秒
Good00099.9%2,0017.8〜8.6 秒

1,000 訪問 × 3 回。後輩A は nginx の鍵だけ書き換えて測定(sim_proposalA_out.txt)。

後輩A: 採らない

  • 直る: LP の漏えいと Cookie の受け渡し
  • 直らない: API の漏えい(アプリは Bearer で呼ぶので Cookie を鍵に足しても API の鍵は全員同じ)
  • 新たに壊れる: ヒット率 0%(1 人 1 回しか開かない LP を会員ごとに保存しても 2 回目が来ない)。origin の処理時間は Bad より 4 割増。キャッシュ容量が会員数×UA 数に比例。安全性が「鍵に Cookie 名を正しく書いたか」という前段の 1 行に依存

後輩B: 正しさは本物。ただし費用で採らない

  • 直る: 漏えいはすべて止まる(no-store はどの前段も保存しない)。価格の誤表示も直る
  • 代償 導出: 420 req/s × 描画 30 ms =毎秒 12.6 秒分の CPU。Good は描画 60 秒に 1 回+会員 API 420 × 3 ms =毎秒 1.26 秒分で、約 10 分の 1(30 ms・3 ms はどちらも仮定)
  • 危ないのは「会員ごとの応答を共有すること」で、キャッシュそのものではない。no-store にする範囲は会員 API だけで済む

改善後コード(Good)

設計表: 応答を 3 種類に分け、それぞれ「中身を変える入力」と Cache-Control を 1 対 1 に決める。

応答URLCache-Controlキャッシュ層中身を変える入力
LP の外枠/campaign/autumnpublic, max-age=0, s-maxage=≤60, swr=≤30, sie=≤600(切替 1 秒前で切る)保存。鍵 $uri、lock on時刻(価格)だけ
会員ごとの部分/api/me/summaryprivate, no-store+Set-Cookie はここだけ保存しないCookie
アプリのクーポン一覧/api/me/couponsprivate, no-store保存しない+proxy_no_cache $http_authorizationAuthorization

origin_good.py(要点。全 162 行はコードディレクトリ)

SHARED_TTL_SEC = int(os.environ.get("LP_TTL", "60"))  # 共有キャッシュの基準寿命
STALE_WHILE_REVALIDATE_SEC = 30    # 期限切れ後、裏で取り直す間に古い版を返してよい秒数
STALE_IF_ERROR_SEC = 600           # origin 障害時に古い版を返してよい秒数(10 分)
MIN_CACHEABLE_SEC = 1              # これ未満しか残っていなければ保存させない
# 共有キャッシュの時計は秒単位で、期限の比較も秒単位。実測で切替後 0.3 秒まで旧価格が出たため 1 秒手前で切る
CACHE_CLOCK_MARGIN_SEC = 1
PRIVATE_NO_STORE = "private, no-store"


@dataclass(frozen=True, slots=True)
class SharedCachePolicy:
    """共有キャッシュ(CDN・nginx)に保存してよい応答の寿命。

    完全コンストラクタ: 寿命が負になる・stale が切替時刻を越える組み合わせは作れない。
    """

    s_maxage: int
    stale_while_revalidate: int
    stale_if_error: int

    def __post_init__(self) -> None:
        if min(self.s_maxage, self.stale_while_revalidate, self.stale_if_error) < 0:
            raise ValueError(f"negative lifetime: {self}")

    @classmethod
    def until_next_change(cls, now: float, next_change: float | None) -> SharedCachePolicy | None:
        """次の内容の切替時刻を越えない寿命を決める。

        Args:
            now: 現在の UNIX 時刻。
            next_change: 次に表示内容(価格など)が変わる UNIX 時刻。予定が無ければ None。

        Returns:
            寿命。切替まで MIN_CACHEABLE_SEC 未満しか無ければ None(保存させない)。
        """
        if next_change is None or next_change <= now:
            return cls(SHARED_TTL_SEC, STALE_WHILE_REVALIDATE_SEC, STALE_IF_ERROR_SEC)
        remaining = math.floor(next_change - now) - CACHE_CLOCK_MARGIN_SEC
        if remaining < MIN_CACHEABLE_SEC:
            return None
        ttl = min(SHARED_TTL_SEC, remaining)
        room = remaining - ttl
        return cls(ttl, min(STALE_WHILE_REVALIDATE_SEC, room), min(STALE_IF_ERROR_SEC, room))

    def header(self) -> str:
        # max-age=0: ブラウザには保存させない(ブラウザの中身は私たちから消せないため)
        return (f"public, max-age=0, s-maxage={self.s_maxage}, "
                f"stale-while-revalidate={self.stale_while_revalidate}, stale-if-error={self.stale_if_error}")


class Handler(BaseHTTPRequestHandler):
    def _send(self, status: int, body: bytes, headers: dict) -> None:
        headers.setdefault("Cache-Control", PRIVATE_NO_STORE)
        ...

    def _campaign_shell(self) -> tuple[int, bytes, dict]:
        """誰が見ても同じ LP の外枠。Cookie・クエリ・User-Agent を一切読まない(読まないから共有できる)。"""
        time.sleep(RENDER_SEC)
        now = time.time()
        price = SALE_PRICE_YEN if now < SALE_END else REGULAR_PRICE_YEN
        body = ("<html><body><h1>秋のセール</h1>"
                '<div id="me" data-src="/api/me/summary"></div>'   # 会員ごとの部分はブラウザが別に取りに行く
                f'<p class="price">{price:,} 円</p></body></html>').encode()
        policy = SharedCachePolicy.until_next_change(now, next_price_change(now))
        headers = {"Content-Type": "text/html; charset=utf-8",
                   "Cache-Control": policy.header() if policy else PRIVATE_NO_STORE}
        return 200, body, headers

    def _personal(self, member: dict | None, refresh_session: bool) -> tuple[int, bytes, dict]:
        """会員ごとの応答。Cache-Control は既定の private, no-store のまま(_send が付ける)。"""
        ...
        if refresh_session:
            headers["Set-Cookie"] = f"sid={member['sid']}; Path=/; Max-Age=1800; HttpOnly; Secure; SameSite=Lax"
        return 200, body, headers

nginx_good.conf(要点)

# 既定: 保存しない(proxy_cache を書かない location は一切キャッシュされない)
location / {
    proxy_pass http://origin;
}
# 保存してよいのは、誰が見ても同じ中身だと origin が約束したページだけ
location /campaign/ {
    proxy_pass http://origin;
    proxy_cache edge;
    proxy_cache_key $scheme$host$uri;        # utm_* は鍵に入れない。LP はクエリで中身を変えない契約
    proxy_cache_lock on;                     # 期限切れの瞬間に origin へ行くのは 1 本だけ
    proxy_cache_lock_timeout 3s;
    proxy_cache_lock_age 3s;
    # stale の可否は origin のヘッダだけで決める。proxy_cache_use_stale を書くとヘッダより優先され、
    # 価格の切替時刻を越えて古い価格を出す
    proxy_cache_background_update on;
    proxy_no_cache $http_authorization;      # 多層防御(nginx は RFC 9111 §3.5 を自分では守らない)
    proxy_cache_bypass $http_authorization;
}

Cloud CDN 対応表(本番へ持ち込むとき)

nginxCloud CDN注意
location / で保存しないcache mode USE_ORIGIN_HEADERS(FORCE_CACHE_ALL は使わない)FORCE_CACHE_ALL は private な応答も保存する(公式が警告)
鍵 $scheme$host$uricache key policy でクエリを外す
proxy_cache_lock onrequest coalescing(既定で有効)
ヘッダの stale-while-revalidate尊重する。serve-while-stale の既定は 86,400 秒価格切替を越えないか、本番前に S3 を Cloud CDN で流す
ヘッダの stale-if-error(S4 で効いた)効かないnginx の実測をそのまま持ち込めない
Authorization 付きは proxy_no_cache§3.5 どおり保存しないorigin が private, no-store を名乗るのが両方に効く唯一の手

テストと変異(本物の nginx を起動して叩く)

def test_second_member_never_sees_first_members_data(stack):
    _page_for(stack, ALICE)                     # Alice が先に開いて、キャッシュが温まる
    body, _ = _page_for(stack, BOB)             # 同じ URL を Bob が開く
    assert ALICE["member_id"] not in body
    assert ALICE["coupon_code"] not in body


@pytest.mark.parametrize("path,auth", [("/api/me/summary", "cookie"), ("/api/me/coupons", "bearer")])
def test_personal_responses_declare_private_at_origin(stack, path, auth):
    """nginx を通さず origin を直接見る(変異3で緑のまま通った穴)。"""
    ...
    assert {"private", "no-store"} <= directives
対象結果
Good19 passed(1.34 秒)
Bad6 failed(漏えい・Cookie・API・クエリ分断・Set-Cookie と Vary・origin の宣言)
変異1: 時計の余裕 0 秒8 failed
変異2: nginx の鍵を $request_uri に戻す1 failed
変異3: 既定を public, max-age=60 に最初は 17 passed(緑のまま) → origin を直接見るテストを足して 2 failed
変異3 が緑だったのは、nginx の許可リスト(/api/ は保存しない)が origin の誤りを覆い隠したから。前段を Cloud CDN に替えれば漏れる穴。多層防御は、層ごとに単独で検査するテストが要る。

実行例(input → output。実測)

シナリオ見たものBadGood
S1 本番の朝(1,000 訪問)他人のページを見た人344〜3450
他人のセッション Cookie344〜3450
他人のクーポン(API)988〜9980
LP ヒット率34.4〜34.5%99.9%
origin 処理時間22.4〜23.6 秒7.8〜8.6 秒
S2 期限切れ直後に同時 200 本origin に届いた LP 要求73〜2001
p99219〜4,003 ms56〜252 ms
S3 セール終了をまたぐ終了後の旧価格532〜559(100%)0(余裕 0 秒では 21 件・最大 0.3 秒遅れ)
origin の LP 要求(16 秒)417〜53
S4 origin 停止中に期限切れ応答502200(STALE)

SharedCachePolicy.until_next_change(基準 TTL 60 秒)

切替まで 3600 秒 → public, max-age=0, s-maxage=60, stale-while-revalidate=30, stale-if-error=600
切替まで   95 秒 → public, max-age=0, s-maxage=60, stale-while-revalidate=30, stale-if-error=34
切替まで    8.4 秒 → public, max-age=0, s-maxage=7, stale-while-revalidate=0, stale-if-error=0
切替まで    1.5 秒 → private, no-store(保存させない)
切替の後(予定なし) → public, max-age=0, s-maxage=60, stale-while-revalidate=30, stale-if-error=600

S3 の代償: 切替前の約 2 秒は no-store なので全部 origin へ。本番 420 req/s なら約 840 本 × 30 ms ≒ 25 秒分の CPU が 2 秒に集中する 導出。

迷った案と採らなかった理由

案採らなかった理由前提が変わったら
鍵に Cookie(後輩A)ヒット率 0%、API 漏えい 994〜999 件が残る―
全部 no-store+Pod 3 倍(後輩B)origin の処理量が約 10 倍(導出)外枠すら会員ごとに変わる(全面レコメンド)なら近づく
ESI で会員部分を CDN が組み立てるCloud CDN は ESI を持たないFastly・Akamai に移るなら
Next.js Partial Prerendering考え方は同じ。Python origin に Next.js を入れるのは範囲外フロントを Next.js に寄せる計画が決まったら
セール終了時に invalidation数分かかり回数制限あり。叩き忘れ・失敗の検知が別に要る時刻が事前に分からない変更(在庫切れ・緊急修正)には必要
proxy_cache_use_stale updating ... を設定に設定がヘッダより優先され、切替を越えて旧価格を出しうる価格を載せないページなら可
時計の余裕 0 秒実測で 21 件・最大 0.3 秒遅れ前段がミリ秒で比べると確かめられたら
UA を端末クラスに正規化して VaryCloud CDN は任意ヘッダの Vary で保存しない本当に別 HTML が要るなら URL を分ける

直した後に見るもの・止め方・事後対応

指標正常こうなったら止める/切り替える
「他人の情報が見えた」問い合わせ、HIT かつ Set-Cookie ありの応答01 件でもキャッシュ層を外す(origin 直に戻す)。漏えいは「遅い」より重い
会員 API のキャッシュ状態常に MISS/BYPASSHIT が 1 件でも出たら同上
LP ヒット率95% 以上(実測 99.9%)80% を割ったら鍵に余計な入力(新しい計測パラメータ・Vary)
origin の LP 要求(1 分)エッジ数 × 1〜2 本急増なら coalescing/lock が効いていない
価格切替前後 2 秒の origin CPU上限の 50% 未満超えたら新価格の外枠を版番号つき URL で先に温めて差し替え
CI のキャッシュ安全テスト(19 件)緑赤ならデプロイを止める
既に起きた漏えい: 氏名・ポイント・クーポンコードが他人に表示されたのは個人データの漏えい。本人が 1,000 人を超えるおそれがあれば個人情報保護法第26条・施行規則第7条第4号で委員会報告(速報 3〜5 日、確報 30 日)と本人通知。セッション Cookie の受け渡しで保存済みカードの不正利用が起き得たなら第2号(財産的被害のおそれ)も検討。判断は法務と。技術側は全セッションの無効化、他人に表示されたクーポンの無効化と再発行、前段ログから「HIT かつ Set-Cookie あり」の件数と期間を出す。

ポイント解説

  1. 鍵に無い入力で中身が変わる応答は、必ず他人に返る: 漏えい件数はヒット件数と同じ(344〜345)。逆に中身を変えない utm が鍵にあったせいでヒット率 34% に落ち、受け入れテストで漏えいが見えなくなった
  2. 安全性を前段の挙動に預けない: nginx は §3.5 を守らない、Cloud CDN は stale-if-error を無視する。origin の private, no-store を唯一の拠り所にし、前段の許可リストは二層目
  3. 既定は保存させない、共有してよい応答だけが名乗る: public を付ける道を SharedCachePolicy 1 か所に閉じた。新しい URL を足したときに安全側に倒れる
  4. 期限は時刻で切る。測ると前段の時計の粒度が出てくる: floor だけでは 21 件の旧価格。1 秒の余裕は仕様を読むだけでは出ず、測って初めて出た
  5. 多層防御の一層が効くと、もう一層の故障がテストに出ない: 層ごとに単独で検査する
パターン使った場所『良いコード/悪いコードで学ぶ設計入門』
完全コンストラクタ・値オブジェクトSharedCachePolicyクラス設計の章
ファクトリメソッドuntil_next_change実践カプセル化の章
安全側の既定値(deny by default)_send の setdefault、nginx の location /設計の悪魔の章
関心の分離外枠と会員 API を URL で分ける関心の分離の章
request coalescing(single flight)proxy_cache_lock―

実務への応用

  • 全 GET を棚卸し: 「URL・中身を変える入力・Cache-Control・前段で保存されるか」を 1 行ずつ。Cookie/Authorization で中身が変わるのに private でない行が候補
  • 受け入れテストに「2 人目」: 同じ URL を別の会員で 2 回目に開く。ステージングの Cloud CDN に向けて流す
  • Terraform で固定: cdn_policy.cache_mode = "USE_ORIGIN_HEADERS"、include_query_string = false。FORCE_CACHE_ALL を OPA(conftest)で禁止
  • 可観測性: DataDog で「cache HIT かつ Set-Cookie あり」の件数(0 が正常)。OTel スパンに http.response.header.cache-control
  • 証券会社の経験: 株価ボードの気配は全員で共有、口座残高は口座ごと。混ぜたまま配信を速くすると、他人の残高が見える事故になる

扱わなかった範囲・一次資料

  • Cloud CDN 上での実測 — nginx だけで測った。Authorization・stale-if-error・Vary・serve-while-stale の扱いが違う。本番前にステージングで同じ sim とテストを流す
  • Web Cache Deception(Omer Gil, Black Hat USA 2017)・Web Cache Poisoning(James Kettle, 2018)— 原理は同じ「鍵に無い入力」だが攻撃者側の話なので別の回に
  • bfcache・Service Worker — ログアウト後の表示は別に確かめる
  • 景品表示法・個人情報保護法の最終判断 — 技術側の要件と条文の該当可能性にとどめ、判断は法務

参照日 2026-10-04。
RFC 9111 HTTP Caching(§3.5, §5.2.2.7, §5.2.2.10)https://www.rfc-editor.org/rfc/rfc9111.html
RFC 5861 stale-while-revalidate / stale-if-error https://www.rfc-editor.org/rfc/rfc5861
nginx ngx_http_proxy_module https://nginx.org/en/docs/http/ngx_http_proxy_module.html(Set-Cookie 付きは既定で保存しない、lock_timeout 後の応答は保存しない、ヘッダの stale 指定は設定より優先度が低い。Authorization による保存禁止の記述なし)
Cloud CDN caching overview https://docs.cloud.google.com/cdn/docs/caching(FORCE_CACHE_ALL の警告、coalescing 既定有効、serve-while-stale 既定 86,400 秒、stale-if-error は効かない)
Steam 2015-12-25 キャッシュ事故(約 34,000 人のページが他人に表示)The Register https://www.theregister.com/2015/12/30/steam_security_blip_explained/ / SteamDB https://steamdb.info/blog/recent-caching-issues-on-steam/
個人情報保護委員会 漏えい等の対応 https://www.ppc.go.jp/personalinfo/legal/leakAction/ / 通則編ガイドライン 3-5 https://www.ppc.go.jp/personalinfo/legal/guidelines_tsusoku/

今日のまとめ

キャッシュの鍵に無い入力(Cookie・Authorization)で中身を変える応答は、必ず他人に返る。会員ごとの部分を別 URL の private, no-store に出し、「誰が見ても同じ」外枠だけが寿命を名乗る形にすると、漏えい 0 件のまま LP ヒット率は 34% から 99.9% に上がった(実測、1,000 訪問)。寿命は価格の切替時刻で切り、前段の時計の粒度ぶん(1 秒)手前に余裕を取る。

自己評価(あとで記入)