● CWE-942 · OWASP A05:2021

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 パッケージは動的なオリジンに対してこれを付けます。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

被害者が API のサイトにログインしている

ブラウザは api.example.com のセッション Cookie を持っており、クロスサイトのリクエストでもそれが送られます。

ステップ 2

攻撃者のドメインのページを開く

スクリプトが fetch('https://api.example.com/api/account', { credentials: 'include' }) を呼び出します。レスポンスの長さをコンソールに出すだけの https://attacker-example.com のページはこの場合を示しています。

ステップ 3

API がそのオリジンを認める

接尾辞のチェックが attacker-example.com を受け入れるので、レスポンスは Access-Control-Allow-Origin にそのオリジンを入れ、Access-Control-Allow-Credentials: true も付けます。

ステップ 4

スクリプトが個人データを読む

ブラウザはアカウントの 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));
});

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →