ALLOWED_ORIGINS に https://example.com を1行登録した。動いた。CORSエラーも出なくなった。これで終わり——と思いきや、そのオリジンが自社ドメインの中でしか意味を持たない前提は、実は最初から崩れていた。
問題は、その1行の登録が「example.com そのもの」だけでなく「example.com で終わる文字列すべて」を許可していたことに気づいていなかった点にある。evil-takeover.example.com も、判定コードにとっては同じ仲間だった(判定側は律儀に文字列の末尾しか見ていない。正直、悪意までは見てくれない)。
自社SaaSのシフト管理アプリのバックエンドで、まさにこの実装がセキュリティ監査で指摘された。この記事では、部分一致CORS判定がなぜサブドメイン乗っ取りの入口になるのか、どう気づいたのか、そしてどう直したのかを、同じ設計をしているかもしれない読者に向けて整理する。
この記事で扱うこと
endsWithによるオリジン判定が、なぜ「そのドメイン配下なら何でもOK」という危険な意味になるのか- 完全一致allowlist・明示的wildcardルール・localhost限定の開発例外、という3層構成への置き換え方
- CORSと根っこが同じ、マルチテナントのなりすまし(クロステナントアクセス)をJWTで検出する方法
- 自分の実装が同じ穴を持っていないか、数分で確かめる手順
対象は特定のフレームワークに限らない。リクエストのOriginヘッダーを文字列として比較し、可否を決めている実装であれば、言語やライブラリを問わず同じ構造の穴が空きうる。
前提条件
- CORS(Cross-Origin Resource Sharing)の基本的な役割を知っていること
- サブドメインを使ったマルチテナントSaaS(
tenant-a.example.comのような構成)を運用、または検討していること - JWTベースの認証を使っていること(後半のクロステナント対策の話)
なぜ「部分一致」が危険なのか
CORSミドルウェアの多くは、origin コールバックに許可/拒否のロジックを自分で書ける。よくある実装はこうだ。
// 危険な例:「configuredなドメイン配下ならOK」という暗黙のルール
function isAllowed(origin: string, allowedHost: string): boolean {
const originHost = new URL(origin).hostname;
return originHost === allowedHost || originHost.endsWith(`.${allowedHost}`);
}
allowedHost に example.com を1つ登録するだけで、app.example.com のような正規のサブドメインも一緒に通したい——という動機で書かれることが多い。実際、動作確認では期待通りに動く。困るのは、この判定が「example.com を末尾に持つ文字列」を全部通してしまうことだ。攻撃者が任意のサブドメインを取得できる、あるいはDNS委任・古いCNAMEレコードの放置などでサブドメインを乗っ取れる状況があれば、evil-takeover.example.com からのリクエストも Access-Control-Allow-Origin の対象になる。これがいわゆるサブドメインテイクオーバー経由のCORS迂回だ。
厄介なのは、テストを書いても「許可したいオリジンが通ること」しか確認しないと、この漏れには気づけない点にある。「通ってほしいものが通る」だけでなく「通ってほしくないものが弾かれる」テストがセットで要る——という話は、以前CORSに限らないガードの穴でも書いた通り、判定ロジック全般に共通する落とし穴だ。
3層のallowlistに置き換える
置き換えの方針は単純で、「暗黙に許可する範囲」をゼロにする。そのうえで、実運用に必要な3パターンだけを明示的なルールとして持つ。
| ルール | 用途 | 許可される例 | 拒否される例 |
|---|---|---|---|
| 完全一致 | 本番のフロントエンドオリジン | https://app.example.com | 上記以外すべて |
| 明示的wildcard(左端1ラベルのみ) | プレビューデプロイなど | https://pr-123.preview.example.com | https://a.b.preview.example.com(多段サブドメイン) |
| localhost限定サブドメイン | 開発時のテナントslug再現 | http://tenant-a.localhost:3000 | https://tenant-a.example.com(本番ドメインには適用しない) |
コードにすると、判定関数はこういう形になる(コアの分岐だけ抜粋、エラーハンドリングは省略)。
const SINGLE_LABEL = /^[a-z0-9-]+$/;
const LOCALHOST_SUBDOMAIN = /^[a-z0-9-]+\.localhost$/;
function createOriginValidator(allowedOrigins: string[]) {
return (origin: string): boolean => {
// 1. 完全一致
if (allowedOrigins.includes(origin)) return true;
const originUrl = new URL(origin);
for (const allowed of allowedOrigins) {
// 2. 明示的wildcard("*.preview.example.com" のように左端1ラベルのみ)
if (allowed.includes('*')) {
const allowedUrl = new URL(allowed.replace('*', 'x'));
if (originUrl.protocol !== allowedUrl.protocol) continue;
if (originUrl.port !== allowedUrl.port) continue;
const rest = allowedUrl.hostname.replace('x', ''); // ".preview.example.com"
if (!originUrl.hostname.endsWith(rest)) continue;
const label = originUrl.hostname.slice(0, -rest.length);
if (SINGLE_LABEL.test(label)) return true;
continue;
}
// 3. localhost限定の開発用サブドメイン一致
const allowedUrl = new URL(allowed);
if (
allowedUrl.hostname === 'localhost' &&
LOCALHOST_SUBDOMAIN.test(originUrl.hostname) &&
originUrl.protocol === allowedUrl.protocol &&
originUrl.port === allowedUrl.port
) {
return true;
}
}
return false;
};
}
ポイントは3つ。
- wildcardは左端1ラベルだけ。
*.preview.example.comはpr-123.preview.example.comを通すが、a.b.preview.example.comのように段が増えたものは通さない。ラベルの中身も英数字とハイフンだけに絞る(そうでないと、今度はラベルの中に別のホストを埋め込むような迂回を許してしまう) - プロトコルとポートまで完全一致で見る。ホスト名だけ合っていてもhttpとhttpsを取り違えて通すと、平文経路からの偽装を許すことになる
- localhostの特例は、ホスト名が文字通り
localhostのエントリにしか適用しない。本番ドメインの設定に対して同じ緩さを持ち込まない(そこを甘くした結果が、そもそも今回の監査指摘だった)
もとの実装が1行の endsWith で済んでいたのに対して、置き換え後は3つの分岐に増える。行数だけ見ると「悪化」に見えるかもしれないが、増えたのは複雑さではなく許可する範囲を明示した結果の行数というわけです。むしろ「どのオリジンが、どの条件で通るか」をコードを読むだけで説明できるようになった分、レビューはしやすくなる。
クロステナントのなりすましも、根っこは同じ「暗黙の信頼」
CORSの穴を塞いだところで、マルチテナントSaaSにはもう1つ似た構造の穴が残りやすい。テナントの解決だ。
サブドメインやカスタムヘッダー(X-Tenant-Slug のようなもの)からテナントを特定する実装は珍しくない。ここで見落としがちなのは、そのヘッダーはクライアントが自由に書き換えられるという点だ。ログイン中のユーザーが、リクエストヘッダーだけ書き換えて別テナントのslugを送ってきたら何が起きるか。サーバー側がヘッダーの言い分をそのまま信じてテナントを解決していると、認証は通っているのに違うテナントのデータにアクセスできてしまう。
対策は、認証時に発行したJWTが持つテナントIDと、リクエストから解決したテナントIDを、認証のたびに突き合わせることだ。
// JWT検証後、テナント解決の結果と突き合わせる
if (request.tenantContext && request.tenantContext.tenantId !== payload.tenantId) {
throw new ForbiddenError('TENANT_CONTEXT_MISMATCH');
}
JWTのペイロードはログイン時にサーバーが署名したものなので、クライアントが書き換えても署名検証で弾かれる。一方でヘッダーから解決した tenantContext はリクエストのたびにクライアント側の入力に依存する。この2つが食い違うということは、正規のログイン時とは別のテナントを名乗ろうとしている、というシグナルになる。一致していない場合は403で止める。
CORSの部分一致もテナントヘッダーの無条件信頼も、「サーバー側が検証していない入力を、信頼できる情報として扱っていた」 という点で同じ構造をしている。片方だけ直して安心してしまいがちだが、もう片方は本当に大丈夫だろうか。リクエストのどの値がクライアント由来で、どの値が署名済み・検証済みかを一度棚卸ししてみると、似た穴がもう1つ2つ見つかることが多い(棚卸しした瞬間に「あ、これも」と気づくやつです)。
動作確認
自分のCORS設定が同じ穴を持っていないか、手元で確かめる方法は2つある。
まず、判定関数を単体でテストする。フレームワークに関係なく、origin を受け取って真偽値を返す純粋関数として切り出せているなら、以下のようなケースを一通り通してみるとよい。
import { describe, expect, it } from 'vitest';
describe('createOriginValidator', () => {
it('完全一致は許可する', () => {
const isAllowed = createOriginValidator(['https://app.example.com']);
expect(isAllowed('https://app.example.com')).toBe(true);
});
it('endsWithの暗黙一致によるサブドメイン乗っ取りは拒否する', () => {
const isAllowed = createOriginValidator(['https://example.com']);
expect(isAllowed('https://evil-takeover.example.com')).toBe(false);
});
it('許可オリジンをprefixに持つだけのホストも拒否する', () => {
const isAllowed = createOriginValidator(['https://app.example.com']);
expect(isAllowed('https://app.example.com.evil.com')).toBe(false);
});
it('明示的wildcardの多段サブドメインは拒否する', () => {
const isAllowed = createOriginValidator(['https://*.preview.example.com']);
expect(isAllowed('https://a.b.preview.example.com')).toBe(false);
});
it('wildcardエントリでもプロトコル違いは拒否する', () => {
const isAllowed = createOriginValidator(['https://*.preview.example.com']);
expect(isAllowed('http://pr-1.preview.example.com')).toBe(false);
});
});
「許可したいものが通る」テストだけでなく、「通したくないものが弾かれる」テストを先に書いてから実装を直すと、直した"つもり"で終わらない。
次に、実際に動いているAPIに対してブラウザ外からOriginを偽装したリクエストを送り、レスポンスヘッダーを見る。
curl -s -D - -o /dev/null -H "Origin: https://evil-takeover.example.com" https://api.example.com/health
レスポンスに Access-Control-Allow-Origin: https://evil-takeover.example.com のようなヘッダーが返ってきたら、そのオリジンはサーバー側で許可されている。返ってこなければ拒否されている(この1行、思ったより多くのバグをあぶり出してくれる)。あわせて、正規のフロントエンドオリジンを指定した場合にきちんと許可されるかも同じコマンドで確認しておくと、締めすぎ(過剰拒否)にも気づける。
注意点・Tips
endsWithを1文字でも書いたら、それが「配下すべて許可」を意味していないか疑う: CORSに限らず、リダイレクト先ホストの検証やWebhook URLの検証でも同じ書き方をしがちで、同じ穴が空く- wildcardは「左端1ラベルだけ」に絞る:
*.example.comのような素朴なパターンマッチだと、a.b.example.comのような多段サブドメインまで通してしまうことがある。ラベル数を制限しないと、結局endsWithと同じ広さに戻ってしまう - localhostの特例は本番ドメインに波及させない: 開発体験のために緩めたルールを、ホスト名の完全一致以外の条件(プレフィックスなど)で本番にも適用してしまうと、同じ穴を自分で開け直すことになる
- CORSとテナント解決は別レイヤーだが、検証タイミングは近くに置く: オリジン検証で「誰からのリクエストか」を絞り込んでも、テナント解決で「誰のデータか」を検証していなければクロステナントアクセスは防げない。両方を認証フローの中で毎回チェックする
まとめ
CORSのオリジン判定を endsWith の部分一致で書くと、「そのドメイン配下ならどのサブドメインでもOK」という、意図していなかったほど広い許可になる。サブドメインを攻撃者に取られた場合、それがそのままCORS迂回の入口になる。
- 部分一致ではなく、完全一致・明示的wildcard(左端1ラベルのみ)・localhost限定の3層で許可範囲を明示する
- マルチテナントSaaSでは、テナント解決に使うヘッダーもクライアント由来の入力である以上、JWTの署名済みテナントIDと突き合わせて検証する
- 「通したいものが通る」テストだけでなく「通したくないものが弾かれる」テストをセットで書く。あなたの実装でも、この2種類のテストが揃っているかは一度確認する価値がある
自分のCORS設定が同じ形をしていないか、まずは evil-takeover. のようなダミーサブドメインをOriginに指定して1回叩いてみるところから始められる。



