概要
鍵に無い入力で中身が変わる応答は、必ず他人に返る
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倍に」
設問
- Bad の問題点を 7 つ以上(何が誰に起きるか、関係する仕様)
- ステージングの受け入れテストが全件緑だった理由
- 後輩A・B の提案を測って評価
- 改善後の origin コードとキャッシュ層の設定(価格切替をまたいで旧価格を出さない)
- Bad に流すと赤になるテスト(本物のキャッシュ層を挟んで)
- 監視・切替条件・既に起きた漏えいへの対応(法令上の報告の要否を含む)
制約・前提条件
- origin は Python 3.12+。キャッシュ層は本番 Cloud CDN、検証は nginx。どちらか一方の挙動に依存した安全性は避ける
- 会員ごとの部分(名前・ポイント・クーポン)は消せない(「あなた専用」表示が CVR を上げると分かっている)。
utm_*も計測チームが使うので消せない - セール価格は終了時刻を1秒でも過ぎたら出さない(表示とカートの食い違いは問い合わせと、景品表示法の有利誤認の疑いにつながる)
- CDN の invalidation は反映に数分かかり回数制限もあるため、通常運用の前提にしない
Bad → Good(対比)
# ミドルウェア: 全 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)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 は「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 なし |
| 2 | proxy_ignore_headers Set-Cookie | 他人のセッション Cookie が届きアカウント乗っ取り。344〜345 人 | nginx は既定で Set-Cookie 付きを保存しない。その安全装置を外した |
| 3 | API に max-age=60(「Authorization 付きは保存されない」と思い込み) | 1 人目のクーポン一覧が全員に。988〜998 / 1,000 人、origin に届いたのは 2 本 | §3.5 は保存禁止だが nginx は未実装。Cloud CDN は守る=前段で安全性が変わる |
| 4 | 鍵に会員ごとの utm_content | push 経由 60% が全部ミス。LP ヒット率 34.4〜34.5% | 鍵には中身を変える入力だけ。Cloud CDN は includedQueryParameters |
| 5 | Vary: User-Agent | UA の種類数だけ版が割れる(50 種類で測定。実際は数千) | Cloud CDN は許可外の Vary だと保存しない |
| 6 | proxy_cache_lock 未設定 | 期限切れ直後の同時 200 本のうち 73〜200 本が origin へ | nginx 既定 off。Cloud CDN は coalescing 既定 on |
| 7 | 寿命 300 秒が価格切替を考えない | セール終了後の要求の 100%(532〜559 件)に旧価格。窓を延ばせば最大 300 秒 | invalidation は数分かかる |
| 8 | ブラウザにも 300 秒、障害時 stale の方針なし | 共用端末でログアウト後も前の人のページ。origin 停止中は 502 | max-age はブラウザも従う。s-maxage は共有キャッシュだけ |
受け入れテストが緑だった理由
QA は push リンクから 200 人分を開いた。push リンクは会員ごとに違う utm_content を持つので鍵が全員違い、全員ミス。一度もヒットしないから漏れもしなかった。
| 実装 | 訪問 | LP の漏えい | 他人の Cookie | LP ヒット率 | API の漏えい |
|---|---|---|---|---|---|
| Bad・S1b(全員 push 経由=受け入れテスト) | 200 | 0 | 0 | 0.0% | 198〜199 |
| Bad・S1(本番の朝。直接訪問 40%) | 1,000 | 344〜345 | 344〜345 | 34.4〜34.5% | 988〜998 |
後輩の提案の評価(実測)
| 実装 | LP 漏えい | 他人の Cookie | API 漏えい | LP ヒット率 | origin 要求 | origin 処理時間 |
|---|---|---|---|---|---|---|
| Bad | 344〜345 | 344〜345 | 988〜998 | 34.4〜34.5% | 658〜667 | 22.4〜23.6 秒 |
後輩A(鍵に $cookie_sid) | 0 | 0 | 994〜999 | 0.0% | 1,001〜1,006 | 32.7〜32.8 秒 |
| Good | 0 | 0 | 0 | 99.9% | 2,001 | 7.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 に決める。
| 応答 | URL | Cache-Control | キャッシュ層 | 中身を変える入力 |
|---|---|---|---|---|
| LP の外枠 | /campaign/autumn | public, max-age=0, s-maxage=≤60, swr=≤30, sie=≤600(切替 1 秒前で切る) | 保存。鍵 $uri、lock on | 時刻(価格)だけ |
| 会員ごとの部分 | /api/me/summary | private, no-store+Set-Cookie はここだけ | 保存しない | Cookie |
| アプリのクーポン一覧 | /api/me/coupons | private, no-store | 保存しない+proxy_no_cache $http_authorization | Authorization |
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 対応表(本番へ持ち込むとき)
| nginx | Cloud CDN | 注意 |
|---|---|---|
location / で保存しない | cache mode USE_ORIGIN_HEADERS(FORCE_CACHE_ALL は使わない) | FORCE_CACHE_ALL は private な応答も保存する(公式が警告) |
鍵 $scheme$host$uri | cache key policy でクエリを外す | |
proxy_cache_lock on | request 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
| 対象 | 結果 |
|---|---|
| Good | 19 passed(1.34 秒) |
| Bad | 6 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 |
/api/ は保存しない)が origin の誤りを覆い隠したから。前段を Cloud CDN に替えれば漏れる穴。多層防御は、層ごとに単独で検査するテストが要る。実行例(input → output。実測)
| シナリオ | 見たもの | Bad | Good |
|---|---|---|---|
| S1 本番の朝(1,000 訪問) | 他人のページを見た人 | 344〜345 | 0 |
| 他人のセッション Cookie | 344〜345 | 0 | |
| 他人のクーポン(API) | 988〜998 | 0 | |
| LP ヒット率 | 34.4〜34.5% | 99.9% | |
| origin 処理時間 | 22.4〜23.6 秒 | 7.8〜8.6 秒 | |
| S2 期限切れ直後に同時 200 本 | origin に届いた LP 要求 | 73〜200 | 1 |
| p99 | 219〜4,003 ms | 56〜252 ms | |
| S3 セール終了をまたぐ | 終了後の旧価格 | 532〜559(100%) | 0(余裕 0 秒では 21 件・最大 0.3 秒遅れ) |
| origin の LP 要求(16 秒) | 4 | 17〜53 | |
| S4 origin 停止中に期限切れ | 応答 | 502 | 200(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 を端末クラスに正規化して Vary | Cloud CDN は任意ヘッダの Vary で保存しない | 本当に別 HTML が要るなら URL を分ける |
直した後に見るもの・止め方・事後対応
| 指標 | 正常 | こうなったら止める/切り替える |
|---|---|---|
| 「他人の情報が見えた」問い合わせ、HIT かつ Set-Cookie ありの応答 | 0 | 1 件でもキャッシュ層を外す(origin 直に戻す)。漏えいは「遅い」より重い |
| 会員 API のキャッシュ状態 | 常に MISS/BYPASS | HIT が 1 件でも出たら同上 |
| LP ヒット率 | 95% 以上(実測 99.9%) | 80% を割ったら鍵に余計な入力(新しい計測パラメータ・Vary) |
| origin の LP 要求(1 分) | エッジ数 × 1〜2 本 | 急増なら coalescing/lock が効いていない |
| 価格切替前後 2 秒の origin CPU | 上限の 50% 未満 | 超えたら新価格の外枠を版番号つき URL で先に温めて差し替え |
| CI のキャッシュ安全テスト(19 件) | 緑 | 赤ならデプロイを止める |
ポイント解説
- 鍵に無い入力で中身が変わる応答は、必ず他人に返る: 漏えい件数はヒット件数と同じ(344〜345)。逆に中身を変えない utm が鍵にあったせいでヒット率 34% に落ち、受け入れテストで漏えいが見えなくなった
- 安全性を前段の挙動に預けない: nginx は §3.5 を守らない、Cloud CDN は
stale-if-errorを無視する。origin のprivate, no-storeを唯一の拠り所にし、前段の許可リストは二層目 - 既定は保存させない、共有してよい応答だけが名乗る:
publicを付ける道をSharedCachePolicy1 か所に閉じた。新しい URL を足したときに安全側に倒れる - 期限は時刻で切る。測ると前段の時計の粒度が出てくる:
floorだけでは 21 件の旧価格。1 秒の余裕は仕様を読むだけでは出ず、測って初めて出た - 多層防御の一層が効くと、もう一層の故障がテストに出ない: 層ごとに単独で検査する
| パターン | 使った場所 | 『良いコード/悪いコードで学ぶ設計入門』 |
|---|---|---|
| 完全コンストラクタ・値オブジェクト | 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 秒)手前に余裕を取る。