0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Datadogを勉強していたら、Cookieが生まれた1994年まで遡ることになった

0
Posted at

監視ツールをちゃんと触ることになって、Datadogの勉強を始めました。

3本柱(メトリクス・ログ・トレース)あたりまでは順調でした。課金体系も、まあ複雑だけど理解できる。ところがフロントエンドとバックエンドをつなぐ話に入った瞬間、急に足元が崩れて、気づいたら1994年のNetscapeの話を読んでいました。

この記事はその迷子の記録です。Datadogの入門記事のつもりで書き始めたのに、半分以上がCORSとCookieの話になっています。でも、この順番で辿ったからこそ腑に落ちたので、そのまま書きます。

なお、技術的な記述は公式ドキュメントで裏を取りました。裏が取れなかったものは書いていません(最後に参考文献をまとめています)。


Datadogの3本柱を先に押さえる

まずここだけ。あとの話に必要なので。

Datadogが集めているデータは大きく3種類です。病院にたとえると分かりやすかったので、その形で書きます。

種類 病院でいうと 答えてくれる問い
メトリクス 体温・血圧 異常が「起きてる」か?
ログ カルテ 「何が」起きたか?
トレース (APM) 造影剤入りCT 「どこで」詰まっているか?

3つ目のトレースがイメージしづらかったんですが、実物を見たら一発でした。1リクエストの中で、どこに何ミリ秒使ったかを分解して見せてくれるやつです。

POST /api/websites          合計 2,340ms
├─ 認証チェック               12ms  |
├─ SELECT users                8ms  |
├─ 外部AIサービスを呼ぶ    2,180ms  ████████████████████
├─ S3にアップロード           95ms  |
└─ INSERT websites            40ms  |

「なんか重い」が「外部API呼び出しが93%」に変わる。これは強い。

調査の順番も決まっていて、メトリクス → ログ → トレース、と漏斗のように絞っていきます。いきなりログをgrepし始めると溺れます(一敗)。

ここで一個、独立して役に立つ話

メトリクスの話で一番刺さったのがこれです。

あるAPIのレスポンスタイムを100件測ったとします。

95件 →  100ms
 5件 → 8000ms
------------------
平均 = 495ms

ダッシュボードには「平均レスポンスタイム 495ms」と出る。500ms以内だしヨシ、となる。

でも495msを体験したユーザーは0人です。実在するのは100msの人と8秒の人だけ。

件数
 95|  ███
   |  ███
  5|  ███                                    ███
   +--+------------------------------------+----
    100ms            ↑                    8000ms
                  平均 495ms
              (ここには誰もいない)

だからp95やp99を見ろ、という話になります。ここまでは知識として知っていたんですが、次の計算を見て青くなりました。

1ページ表示に API を 20回 叩くアプリの場合

1回が速い確率        = 99%
20回とも速い確率      = 0.99^20 ≒ 82%
--------------------------------------
→ 18% のページ表示が「遅いやつを踏む」

「1%のリクエストが遅い」は「1%のユーザーが遅い」ではないんですね。p99を舐めてはいけない。

課金でいちばん事故るところ

Datadogのログ課金は2段階に分かれています。

             $0.10/GB              $1.70 / 100万件
                |                        |
アプリ ──▶ [取り込み] ──┬──▶ [インデックス] ──▶ 検索できる
                        |
                        └──▶ [アーカイブ] ──▶ ほぼタダ。検索は不可

1日1000万行、1行1KBのサービスで概算すると、

取り込み  300GB × $0.10           =  $30/月
索引15日  3億件 × $1.70/100万件    = $510/月

94%がインデックス代です。だからDatadogの推奨は「全部取り込む、でも索引は絞る」。ヘルスチェックとINFOログを除外フィルタで落として、残りはアーカイブに逃がす。

それともう一つ、タグの罠。Datadogは「メトリクス名 × タグの組み合わせ」1つ1つを別々のカスタムメトリクスとして数えます。組み合わせなので掛け算です。

env(3種) × service(5種) × status(4種)              =      60個
env(3種) × service(5種) × status(4種) × user_id(10万種) = 600万個

タグを1個足しただけで10万倍。user_idrequest_idemail は絶対にメトリクスのタグにしない。

じゃあユーザー単位の調査はどうするのかというと、それはログとトレースの仕事です。ここは「傾向はメトリクス、個別調査はログ/トレース」と覚えておけば良さそうです。この原則、Datadogに限らずどの監視ツールでも同じでした。


本題:フロントエンドをつなごうとして詰まる

さて、ここからが本題です。

Datadogにはブラウザ側を監視するRUM(Real User Monitoring)というものがあって、これを入れるとフロントとバックエンドのトレースがつながる、と書いてある。

つながると何が嬉しいかというと、こうなります。

ユーザーが「保存」をクリック
  → RUMが記録
  → APIを呼ぶ(2340ms)
  → その2340msの内訳をAPM側で展開できる
      ├─ mysql      8ms
      ├─ 外部API 2180ms  ← 犯人
      └─ s3        95ms

やりたい。やりたいんですが、ここで最初の疑問が湧きました。

疑問1: trace_idって途切れるのでは?

フロントエンドとバックエンドが完全に分離していて、どのAPIをどの順で叩くかはフロントが決めている。この場合、1回のユーザー操作で3本のAPIを叩いたら、trace_idはバラバラになりますよね?

調べたら、バラバラになるのが正しい、でした。

トレースの定義は「1リクエストの旅」です。fetch 1本 = 1トレース。3本叩けば3トレース。これは壊れているのではなく仕様です。

では「ユーザーの一連の操作」は誰がまとめるのか。それがRUM側の階層でした。

session_id   ユーザーの訪問まるごと
 └ view_id    1画面
    └ action   「保存ボタンをクリック」1回
       ├ resource: POST /api/draft    → trace_id: aaa111
       ├ resource: POST /api/generate → trace_id: bbb222
       └ resource: GET  /api/status   → trace_id: ccc333

つまり2軸あるんですね。

  • trace_id が「縦」につなぐ(フロント → API → DB)
  • session_id / view_id / action が「横」につなぐ(操作① → 操作② → 操作③)

この2軸があるから、trace_idが途切れていても画面上では迷子にならない。RUM Explorerでセッションを開いて、アクションを開いて、その中のAPIをクリックすると「View Trace in APM」でバックエンドのフレームグラフに飛べる、という導線になっています。

ちなみにHTTPを離れる境界(キューを挟んだ非同期ジョブなど)は、ここでも本当に切れます。こればかりは仕組み上どうしようもないので、コンテキストをジョブのペイロードに詰めるか、job_id みたいな相関IDを自分でスパンタグとログ属性に刺すことになります。これはDatadog固有の話ではなく、OpenTelemetryでも同じところで切れます。

疑問2: そして、CORSで全部死ぬ

で、RUMの設定を読んでいて、こういう注意書きに出会いました。

x-datadog-trace-id などのヘッダーはCORS-safelistedではないので、サーバー側で Access-Control-Allow-Headers に追加し、プリフライトのOPTIONSに応答する必要がある

実際に踏むとこういうエラーが出るらしい。

Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
Request header field x-datadog-origin is not allowed by
Access-Control-Allow-Headers in preflight response.

正直に言うと、この時点で私はCORSを「たまに出てくる邪魔なエラー」くらいにしか理解していませんでした。Access-Control-Allow-Origin: * を書いたら消えるやつ、くらいの認識。

なので、いったんDatadogを置いて、CORSを1から調べることにしました。

そしたら1994年まで戻ることになりました。


1994年、Cookieが生まれる

そもそも、なぜブラウザは他のサイトへのアクセスを制限するのか。

話はHTTPがステートレスだったところから始まります。1990年代初頭のWebには、状態を持つ仕組みがありませんでした。

ブラウザ 「ログインします。user=xxx pass=xxx」
サーバー 「認証成功です」
ブラウザ 「じゃあマイページ見せて」
サーバー 「……どちら様ですか?」

1秒前を覚えていない。シンプルで頑丈な設計ですが、ショッピングカートが作れない。

そこで1994年、Netscapeのエンジニアだった Lou Montulli 氏がCookieを発明します。きっかけは顧客からの「ショッピングカートを作りたい」という要望でした。名前の由来はUnix界隈にあった "magic cookie"(プログラム間で受け渡す、中身を詮索しない小さなデータ)から来ています。

仕組みは驚くほど単純です。

① ログイン成功時
   サーバー → Set-Cookie: session=abc123  「これ持っといて」

② 以降のリクエスト
   ブラウザ → Cookie: session=abc123      「はい、これです」

記憶喪失の人に名札を持たせた。天才的な発明です。

ただ、この仕様には穴があった

Cookieのルールはこうです。

bank.com 宛のリクエストには、そのリクエストを誰が発生させたかに関係なくbank.com のCookieが自動で付く

「誰が発生させたか関係なく」。ここが30年間Webを悩ませ続けます。

ユーザーは bank.com にログイン中(session=abc123 を保持)
  ↓ 別タブで悪意あるサイトを開く
悪意あるサイトのHTML:
  <img src="https://bank.com/transfer?to=hacker&amount=1000000">
       ↑ 見た目はただの画像タグ。実体は bank.com へのGETリクエスト
  ↓
ブラウザ「bank.com 宛だからCookieを付けよう」
  ↓
bank.com「session=abc123、本人だ。送金しました」

これがCSRF(Cross-Site Request Forgery)です。ユーザーは画像を見ただけ。

なぜブラウザは止めなかったのか

「危ないなら止めればいいのに」と思ったんですが、止められなかったんですね。

Webの根本思想は「あらゆるものが自由にリンクし合う」ことです。他サイトの画像を貼る、CDNからJSを読む、他サイトにフォームを送信する、リンクを踏む。これらは全部クロスオリジンのリクエストです。

「別オリジンへのリクエストを禁止」にしたら、世界中のWebサイトが一斉に壊れる。

Webにはバージョンアップがありません。1996年に作られたページも今のブラウザで動かないといけない。だから変更は何年もかけて段階的にしか入れられない。

その制約の中でブラウザが選んだのが、**「送るのは許す。でも返ってきた中身は絶対に読ませない」**でした。


Same-Origin Policyは「読み取り」の壁だった

ここで一つ誤解が解けました。

私はずっと「別オリジンへのリクエストはブロックされる」と思っていたんですが、違いました。リクエストは飛びます。レスポンスを読ませないだけです。

まず「オリジン」の定義から。

https :// store.example.com : 443 / dir/page.html?q=1
└─┬─┘     └──────┬───────┘  └┬┘   └────────────────┘
スキーム       ホスト        ポート   ここは含まれない
  └─────────────┴────────────┘
        この3つ組がオリジン。1つでも違えば別オリジン

example.comwww.example.com も別オリジンだし、httphttps も別オリジンだし、:3000:3001 も別オリジンです。「同じサイトっぽい」は一切通用しません。

で、何がブロックされて何がされないか。MDNの表を自分用に整理するとこうなりました。

やること 結果
<img src="別オリジン"> 表示できる
<script src="別オリジン"> 実行できる
<link rel="stylesheet" href="別オリジン"> 適用される
<form action="別オリジン"> の送信 送信できる
<iframe src="別オリジン"> 表示できる
↑のiframeの中身をJSで読む ブロック
fetch() のレスポンスを読む ブロック

読み取りだけ塞がれている。

つまりSOPはCSRFを防がない

上の表を見て気づいたんですが、<img><form> も普通に通るなら、さっきのCSRFはSOPがあっても成立しますよね。

これ、OWASPがはっきり明言していました。

Same-origin policy and CORS do NOT prevent CSRF. Browsers automatically include cookies with cross-site requests, which is precisely why CSRF vulnerabilities exist.
OWASP CSRF Prevention Cheat Sheet

攻撃には方向が2つあって、それぞれ別の仕組みで守られています。

読み取り(データを盗む)     → Same-Origin Policy / CORS
書き込み(勝手に操作させる) → CSRFトークン / SameSite / GETで状態を変えない

「CORSを設定したからセキュリティは万全」は完全に間違いでした。ここは自分の理解が雑だったところです。

ついでに <script src> の話。これはレスポンスがJavaScriptとして実行されるので、<img> よりタチが悪い。実際、JSONを配列で返していると、それが有効なJavaScriptの配列リテラルとして評価されてしまう問題が過去にありました。OWASPの対策は明快で、**「配列ではなくオブジェクトで返せ」**です。

危険:  [{"object": "inside an array"}]
安全:  {"object": "not inside an array"}
安全:  {"result": [{"object": "inside an array"}]}

転換点:CORSは「制限」ではなく「緩和」だった

ここが一番の発見でした。

CORS = Cross-Origin Resource Sharing。「クロスオリジンでのリソース共有」です。

共有を禁止する仕組みではなく、共有を可能にする仕組みでした。

私の誤解:
  「CORSがリクエストをブロックしている」
  「CORSを無効にすれば通るはず」
      ↑ 逆。CORSを消したら何も通らなくなる

実際:
  ブロックしているのは Same-Origin Policy(常時ON、無効化不可)
  CORSはその鍵を開けるための唯一の合法的な手段

つまり「CORSエラー」の正体は、**「サーバーが許可証を出してくれなかった」**という意味だったんですね。名前でずっと勘違いしていました。

流れはこうです。

① ブラウザが発信元を自動申告
   GET /api/data
   Origin: https://app.example.com      ← ブラウザが必ず付ける。JSからは偽装不可

② サーバーが許可証を返す
   HTTP/1.1 200 OK
   Access-Control-Allow-Origin: https://app.example.com

③ ブラウザが照合
   Origin と Allow-Origin が一致 → JSにレスポンスを渡す
   不一致 → 破棄してTypeError

そしてcurlではCORSエラーは起きない

これも大きかった。

$ curl https://api.example.com/data
{"balance": 1000000}     ← 普通に返ってくる

CORSを判定して破棄するのはブラウザの仕事だからです。curlもPostmanもPythonスクリプトも、そんなルールを持っていない。

CORSは「サーバーを守るセキュリティ」ではない。
CORSが守るのは「ユーザーのブラウザの中にあるデータ」。

なので認証・認可はサーバー側で別途必ず実装する必要があります。当たり前といえば当たり前なんですが、「CORSを厳しくしたからAPIは安全」と思っている状態から抜けられたのは大きい。

デバッグの切り分けにも使えます。curlで成功してブラウザで失敗するなら、それは100%サーバーの許可証の問題です。


プリフライトの定義が、思ってたより美しかった

次に引っかかったのがプリフライト(OPTIONS)です。

なぜ事前確認が要るのか。理由は明快でした。

悪意あるサイトが fetch('https://bank.com/api/account/12345', {method:'DELETE'})

① リクエスト送信 → サーバーがアカウントを削除してしまう
② レスポンスが返る
③ ブラウザ「許可証がないのでJSには渡さない」

→ JSは読めない。でも削除はもう終わっている

送ってから確認では手遅れなケースがある。だから「新しくできるようになったこと」は送る前に許可を取る。

で、その「新しくできるようになったこと」の線引きが、調べてみたら見事でした。

プリフライトが不要な「単純リクエスト」の条件と、HTMLの <form> ができることを並べてみます。

<form> ができること                   単純リクエストの条件
--------------------------------------------------------------
method="GET"                          メソッド: GET
method="POST"                         メソッド: POST
(HEADは昔から可)                     メソッド: HEAD
--------------------------------------------------------------
enctype はこの3つだけ:                 Content-Type はこの3つだけ:
 application/x-www-form-urlencoded     application/x-www-form-urlencoded
 multipart/form-data                   multipart/form-data
 text/plain                            text/plain
--------------------------------------------------------------
カスタムヘッダーは付けられない          カスタムヘッダー禁止

完全一致します。

つまり「単純リクエスト」というのは技術的な難易度の話ではなく、「CORSが生まれる前から <form> タグで送れていたこと」の一覧でした。

昔からできたことを今さら禁止しても意味がない(攻撃者は <form> を使えば同じことができる)。新しくできるようになったことだけ、事前に許可を取る。

この観点を持つと、何がプリフライトを誘発するか暗記しなくても分かります。

Content-Type: application/x-www-form-urlencoded → formで送れた   → 不要
Content-Type: application/json                  → formで送れない → 必要
method: PUT / DELETE / PATCH                    → formにない     → 必要
Authorization: Bearer xxx                       → formで付けられない → 必要
x-datadog-trace-id: xxx                         → 同上           → 必要  ← これ

最後の行が、最初のエラーの正体です。


自分で対策を考えたら、それは既にSameSiteだった

途中で「そもそもCookieの仕様がおかしいのでは」と思って、こう考えました。

他のサイトから来たリクエストにはCookieを載せなければいい。判定は簡単で、ブラウザのアドレスバーが誰かを見ればいい。リンクを踏んで github.com に遷移したならアドレスバーは github.com なので載せる。悪意あるサイトに埋め込まれた <img src="github.com/..."> ならアドレスバーは悪意あるサイトのままなので載せない。

調べたら、それが SameSite=Lax でした。しかも2020年からブラウザのデフォルトになっていました。

仕様では、アドレスバーに出ているサイトを top-level site、Cookie判定に使う値を site for cookies と呼びます。まさに「アドレスバーで判断する」設計です。

ただし私の案には条件が1つ足りませんでした。

私の案:      ① アドレスバーが遷移先と一致する
SameSite=Lax: ① アドレスバーが遷移先と一致する
             ② かつ 安全なメソッド(GET/HEAD)である  ← これ

②がないと、自動送信フォームが通ってしまいます。

<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="amount" value="1000000">
</form>
<script>document.forms[0].submit()</script>

これ、送信するとページ遷移が起きるのでアドレスバーは bank.com になる。私のルールだとCookieが載ってしまう。だからLaxは「安全なメソッドだけ」という条件を足していました。

Laxで守れる範囲を整理するとこうなります。

クロスサイトのリクエスト Laxでcookieは?
リンクをクリックして遷移(GET) 送る
<img src> 送らない
<script src> 送らない
fetch / XHR 送らない
iframe内の遷移 送らない
別サイトからのform POST 送らない

最初に見たCSRFの <img src="...transfer?..."> は、現代のブラウザではCookieが付かないので成立しません。ただし <a href="...transfer?...">豪華賞品はこちら</a> をユーザーがクリックした場合は、トップレベル遷移のGETなので通ってしまいます。

だから今もCSRFトークンが必要だし、「GETで状態を変えない」が必要なわけですね。全部つながりました。

なぜStrictがデフォルトじゃないのか

ちなみに私の最初の案(=Strict相当)をデフォルトにするとどうなるか。

Slackに貼られた https://github.com/xxx をクリック
  → GitHubが開く
  → Cookieが送られない
  → 「ログインしてください」

メール・SNS・検索結果からの流入が全部ログアウト状態になります。Webが死ぬレベルのUX破壊。だからLaxが妥協点として選ばれています。

ついでに「同じサイト」の定義がオリジンと違う

ここも罠でした。Cookieの判定単位はオリジンではなくサイトです。

              Origin                  Site
        (scheme+host+port)      (scheme + eTLD+1)
--------------------------------------------------
https://app.example.com   別    ┐
https://api.example.com   別    ├ 全部 同一サイト
https://www.example.com   別    ┘

だから名前が "SameSite" であって "SameOrigin" ではない。

eTLD(effective TLD)は「ここから下は誰でも登録できる」境界のことで、.com.co.jp のほか、github.iovercel.app も入っています。

これがないと user-a.github.iouser-b.github.io が同一サイト扱いになって、他人のGitHub PagesがあなたのCookieを読めてしまう。

そしてこの境界リストは Public Suffix List という、人力メンテされているテキストファイルです。全ブラウザがこれを内蔵している。Webのcookie分離の安全性がテキストファイル1枚に支えられているの、なかなかすごい話だと思いました。


そして最初のエラーに戻る

ここまで来て、最初のエラーが完全に読めるようになりました。

① RUM SDKに allowedTracingUrls を設定
     ↓
② SDKがfetchに x-datadog-* を4つ自動注入
     ↓
③ これらは <form> では送れなかったヘッダー = CORS-safelistedではない
     ↓
④ 「単純リクエスト」の条件から外れる
     ↓
⑤ プリフライトが発生する(今まで飛んでいなかったのに)
     OPTIONS /api/data
     Access-Control-Request-Headers: x-datadog-trace-id, ...
     ↓
⑥ サーバーの Allow-Headers にそれらがない
     ↓
⑦ ブラウザが本番リクエストを送らない
     ↓
⑧ 冒頭のエラー

重要なのは、本番リクエストは1つもサーバーに届いていないことです。サーバーのアクセスログにはOPTIONSだけが並ぶ。「POSTしてるのに届かない」という不毛な会議はこれで起きます。

しかも死に方が2パターンあって、後者が厄介でした。

パターンA: もともとJSON APIで、既にプリフライトが飛んでいた
  → Allow-Headers に Content-Type は書いてあった
  → x-datadog-* が増えて照合で落ちる
  → エラーメッセージが親切なので気づける

パターンB: もともとGETの単純リクエストだった
  → 今までプリフライトが飛んでいなかった
  → 誰もOPTIONSのことを考えたことがない
  → ルーターにOPTIONSハンドラがなく 404/405 が返る
  → CORS設定を疑うのに、実はルーティングの問題

修正

サーバー側(Honoの例)。

import { cors } from 'hono/cors'

app.use('*', cors({
  origin: ['https://app.example.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowHeaders: [
    'Content-Type',
    'Authorization',
    // datadog プロパゲーター
    'x-datadog-trace-id',
    'x-datadog-parent-id',
    'x-datadog-origin',
    'x-datadog-sampling-priority',
    // W3C tracecontext プロパゲーター
    'traceparent',
    'tracestate',
  ],
  credentials: true,
  maxAge: 7200,
}))

ヘッダーを減らす手もあります。Datadogは伝搬方式を選べて、W3C標準に寄せると2個で済みます。

allowedTracingUrls: [
  { match: 'https://api.example.com', propagatorTypes: ['tracecontext'] }
]
propagatorTypes 注入されるヘッダー 個数
datadog(既定) x-datadog-trace-id / -parent-id / -origin / -sampling-priority 4
tracecontext traceparent / tracestate 2
b3 b3 1
b3multi X-B3-TraceId / X-B3-SpanId / X-B3-Sampled 3

W3C標準なのでOpenTelemetryなど他ツールとも互換があります。将来乗り換えるならこっちが良さそう。

デプロイ順序が地味に大事

ここは事故りやすいので明示しておきます。

Step 1  バックエンド: allowHeaders に追加してデプロイ
        → まだ誰もそのヘッダーを送ってこないので完全に無害

Step 2  curl でプリフライトを検証

Step 3  フロント: allowedTracingUrls を1本だけ設定してデプロイ
        → 影響範囲を絞って動作確認

Step 4  全体に展開

逆順にすると、フロントをデプロイした瞬間に該当APIが全滅します。「拡張してから移行する」という後方互換デプロイの原則そのものですね。

検証用のcurlはこれです。

curl -i -X OPTIONS 'https://api.example.com/users' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,x-datadog-trace-id'

204が返って、Access-Control-Allow-Headers に指定したヘッダーが全部含まれていればOK。CDNやWAFがヘッダーを削っていることもあるので、CDN経由とオリジン直叩きで差が出ないかも見ておくと安心です。

なお、allowedTracingUrls に書かないURLにはヘッダーが注入されないので、既存APIが壊れる範囲は自分でコントロールできます。RUMを init() するだけならヘッダーは飛ばないので、段階導入も可能です。


おまけ:Cookieなし・GETだけならCORSは要らないのでは?

最後にもう一つ、途中で浮かんだ疑問を書いておきます。

Cookieも使わない、GETしかしない。だったら何のセキュリティリスクもないのでは?

半分正解でした。本当に公開データなら Access-Control-Allow-Origin: * を書けばいい。CORSは「設定しなければならない義務」ではなく「公開だと宣言する手段」です。ブラウザには公開データか内部の秘密かを判別する方法がないので、宣言してもらうしかない。

でも「Cookieがなければ安全」は間違いでした。認証情報はCookieだけではありません。

Cookie
HTTP Basic / Digest 認証(ブラウザが保存して自動再送)
TLSクライアント証明書
NTLM / Kerberos(企業イントラの統合認証)
ネットワークのどこに居るか        ← これ

最後のやつが一番怖い。

悪意あるサイトのJS(Cookie不要、GETだけ)

fetch('http://192.168.1.1/api/status')
  → 家庭用ルーター。認証なしの機種が多い

fetch('http://localhost:9200/_all')
  → 開発PCのElasticsearch。デフォルト認証なし

fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/')
  → クラウドのメタデータサービス。IAMの一時クレデンシャル

ファイアウォールは外からの侵入を防ぎますが、ユーザーのブラウザはすでに内側にいます。攻撃者は外から繋いでいるのではなく、あなたのブラウザに内側から繋いでもらっている。

インターネット                      プライベートネットワーク
+------------+                     +---------------------+
| 悪意サイト  |                     | 192.168.1.1         |
+-----+------+                     | localhost:9200      |
      | JSを配信                    | intra.company.local |
      v                            +----------^----------+
+---------------------------+                 |
| ユーザーのブラウザ          |-----------------+
| (社内LANの内側にいる)     |   fetchが届いてしまう
+---------------------------+

これを止めているのがSame-Origin Policyの読み取りブロックです。

AWSのIMDSv2が、この理屈を防御に使っていた

これが個人的に一番おもしろかった話です。

EC2のメタデータサービスは、v1では認証なしの素のGETでした。v2はこうなっています。

① PUT http://169.254.169.254/latest/api/token
     X-aws-ec2-metadata-token-ttl-seconds: 21600   ← カスタムヘッダー必須
② GET .../meta-data/  with X-aws-ec2-metadata-token

ここまで読んできたなら分かると思います。

PUT          → formでは送れない      → プリフライト必須
カスタムヘッダー → formでは付けられない  → プリフライト必須
     ↓
ブラウザはまずOPTIONSを投げる
     ↓
IMDSはOPTIONSにCORS許可証を返さない
     ↓
本番リクエストは送られない

わざと「単純リクエストでは叩けない形」にしてあるんですね。IMDSv2の主眼はSSRF対策ですが、同じ原理でブラウザ経由も封じられています。CORSの仕組みを攻撃者への障壁として使っている。設計として気持ちいい。

ローカル開発の設定にも注意

これを知って一つ怖くなったのが、開発サーバーのCORS設定です。

localhost:3000 に Access-Control-Allow-Origin: * を設定していると、
悪意あるサイトを開いただけで、そのJSがlocalhostのデータを読める

「localhostは外から見えないから安全」は成り立ちません。リクエストを出しているのは外部のサーバーではなく、自分のブラウザなので。

対策としては、開発でも具体的なオリジンだけ許可するか、そもそもdevサーバーのproxyで同一オリジンにしてしまうのが確実でした。

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': { target: 'http://localhost:3001', changeOrigin: true },
    },
  },
})

フロントは fetch('/api/users') と相対パスで書けるので、そもそもCORSが発生しません。「CORSを正しく設定する」より「CORSが発生しない構成にする」ほうが事故が少ないのは、なかなか実践的な結論だと思います。

ちなみにChromeは2025年10月のバージョン142から、公開サイトからプライベートアドレスへのリクエストにもプリフライトを要求するようになりました(Private Network Access)。ローカル開発サーバーが急にCORSエラーを出し始めた、という報告がその頃に増えています。ブラウザ側でも守りが増えているものの、他ブラウザや古いバージョンでは効かないので、自分で締めるのが基本です。


まとめ

Datadogの入門記事を書くつもりが、大半がCookieとCORSの話になりました。でもこの順番で辿ったから腑に落ちたので、そのまま出します。

一番の収穫は、「なぜそういう仕様になっているか」を歴史から見ると暗記が要らなくなることでした。

  • CORSは「制限」ではなく「緩和」。だからエラーの意味は「サーバーが許可証を出していない」
  • 単純リクエストの定義は「<form> で送れたこと」。だから何がプリフライトを誘発するか導ける
  • SameSiteは「アドレスバーで判断する」。だからどの経路でCookieが飛ぶか導ける
  • SOP/CORSは読み取りの話、CSRFは書き込みの話。レイヤーが違う

そしてもう一つ。Web の仕様がこれだけ複雑なのは、設計が悪いからではなく 既存のサイトを壊せないから でした。1996年に作られてもう誰もメンテしていないサーバーは、CORSヘッダーを返さないので自動的に守られる。「何もしないサーバーが最も安全」になるように設計されている。

30年かけて後から穴を塞いできた歴史そのものだったんだな、と思うと、あの複雑さにもだいぶ納得がいきました。

Datadogのハンズオンはこれからです。


参考文献

Datadog

CORS / Cookie / セキュリティ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?