CORS の設定ミス(CWE-942):Origin の反射がデータを漏らす仕組みと直し方
どんな Origin にもその値をそのまま入れた Access-Control-Allow-Origin と Access-Control-Allow-Credentials: true を返す API は、あらゆる Web サイトに訪問者の Cookie 付きでレスポンスを読ませてしまいます。接尾辞のチェック、null オリジン、反射されたオリジンが同一オリジンポリシーを壊す仕組みと、完全一致の許可リストで直す方法を、Express と cors の例で解説します。
わかりやすい解説 (ELI5)
銀行の窓口係は、あなたが送った使いの人に残高を読み上げてくれます。ただし使いの人が誰の代理かを書いた手紙を見せ、その名前が窓口係のリストで認められている場合に限ります。ある窓口係のルールは「名前が『田』で終わる人なら誰でも認める」でした。見知らぬ人が「山田」の手紙を持たせて使いを出すと窓口係は認めてしまい、あなたが送ってもいない人に残高が読み上げられます。Origin の反射は、窓口係がどんな名前でも認めてしまうもっと手抜きのやり方です。対策は、一字一句照合する短く正確な名前のリストです。
主要な概念と専門用語
- 同一オリジンポリシー
- ブラウザは、スクリプトが自分と同じオリジン(スキーム、ホスト、ポート)のレスポンスしか読めないようにしています。CORS のレスポンスヘッダーは、サーバーが指定したオリジンに対してこのルールを意図的に緩めるための仕組みです。
- 認証情報付きの Access-Control-Allow-Origin
- レスポンスが
Access-Control-Allow-Originに呼び出し元のオリジンを入れ、Access-Control-Allow-Credentials: trueを送ると、そのオリジンのスクリプトはユーザーの Cookie 付きで送られたリクエストのレスポンスを読めます。ブラウザは認証情報と*の組み合わせを拒否するため、設定を誤ったサーバーはオリジンをそのまま返します。 - Origin の反射と接尾辞の照合
Originヘッダーをレスポンスにコピーすると、すべてのサイトを信頼することになります。endsWith('example.com')のようなチェック、部分一致、アンカーがなくドットをエスケープしていない正規表現もhttps://attacker-example.comを受け入れてしまいます。- null オリジン
- サンドボックス化された iframe、
data:URL、一部のリダイレクトはOrigin: nullを送ります。攻撃者なら誰でも作り出せるため、nullを許可するのは全員を許可するのと同じです。 - Vary: Origin
Access-Control-Allow-Originがリクエストによって変わる場合、共有キャッシュがあるオリジン向けの CORS ヘッダーを別のオリジンに返さないよう、レスポンスにVary: Originが必要です。corsパッケージは動的なオリジンに対してこれを付けます。
ステップ・バイ・ステップの攻撃フロー
被害者が API のサイトにログインしている
ブラウザは api.example.com のセッション Cookie を持っており、クロスサイトのリクエストでもそれが送られます。
攻撃者のドメインのページを開く
スクリプトが fetch('https://api.example.com/api/account', { credentials: 'include' }) を呼び出します。レスポンスの長さをコンソールに出すだけの https://attacker-example.com のページはこの場合を示しています。
API がそのオリジンを認める
接尾辞のチェックが attacker-example.com を受け入れるので、レスポンスは Access-Control-Allow-Origin にそのオリジンを入れ、Access-Control-Allow-Credentials: true も付けます。
スクリプトが個人データを読む
ブラウザはアカウントの JSON を攻撃者のスクリプトに渡し、スクリプトはそれをどこへでも送れます。2016 年に James Kettle は複数のビットコイン取引所でこのパターンを見つけ、API キーやウォレットのデータが露出していました。
ソースコード比較:脆弱 vs 堅牢化
// server.js: example.com で終わるオリジンと null が Cookie 付きで読める
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// true を返すと cors は呼び出し元の Origin を認証情報付きで反射する
app.use(cors({
origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
credentials: true,
}));
// 認められたオリジンから読める個人のアカウントデータ
app.get('/api/account', requireSession, (req, res) => {
res.json(accountFor(req.session.userId));
});
// server.js: 完全一致の 2 つのオリジンだけ
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// スキームとホストを含む完全なオリジンを完全一致で比較
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
// 未知または欠けた Origin には CORS ヘッダーを返さない。cors が Vary: Origin を付ける
app.use(cors({
origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ['GET', 'POST'],
maxAge: 600,
}));
// 個人データは共有キャッシュに保存させない
app.get('/api/account', requireSession, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(accountFor(req.session.userId));
});
エンジニアリング&システム堅牢化チェックリスト
- 完全なオリジン(スキーム、ホスト、ポート)を完全一致で照合する許可リストを持ち、
Setの検索で比較する。Originの反射や、接尾辞、部分一致、アンカーのない正規表現によるチェックは使わない。 nullオリジンは決して許可せず、管理していないオリジンに認証情報を許可しない。Access-Control-Allow-Originをリクエストごとに決める場合は必ずVary: Originを送り、個人データを含むレスポンスにはCache-Control: no-storeを付ける。- クロスサイトのリクエストでセッション Cookie が送られないよう、
SameSite=LaxまたはStrictを設定する。 - 認証が必要なルートに
Origin: https://attacker.exampleとOrigin: nullを送り、Access-Control-Allow-Originが返ってきたら失敗させる CI テストを追加する。