event.origin を固定のポート正規化文字列リテラル(event.origin === 'https://app.example.com' など)と比較することは、単純な許可リストでは機能しますが、任意の URL、DOM リンク要素、クロスサブドメイン リダイレクト、サンドボックス化されたメッセージを検証する場合、手動の文字列比較と正規表現は機能しません。
- 接頭辞と部分文字列のなりすまし:
url.startsWith('https://app.example.com')のチェックが'https://app.example.com.attacker.example'によってバイパスされます。 - 正規化されていない URL: パス、クエリ文字列、または明示的なデフォルト ポート(
'https://app.example.com:443/path')を含む完全な URL は、最初に解析して正規化しないと、文字列の等価性で'https://app.example.com'と一致しません。 - Public Suffix List の誤り:
.でホスト名を分割して同一サイトの関係を確認すると、マルチラベルのパブリック サフィックス(.co.ukや.github.ioなどの共有ホスティング ドメインなど)で失敗し、スキームの不一致(http:とhttps:)が無視されます。 - 不透明な
"null"オリジンの競合: サンドボックス化された iframe(<iframe sandbox="allow-scripts">)とdata:URL は、event.originをリテラル文字列"null"にシリアル化します。originA === originBを使用して 2 つのシリアル化されたランタイム オリジンを比較すると、2 つの無関係な不透明なコンテキスト("null" === "null")が誤って同一視されます。
Origin API(Origin.from()、isSameOrigin()、isSameSite()、opaque)は、ブラウザの URL パーサーと Public Suffix List に基づいて、構造化された仕様に準拠したオリジンとスキーマフルな同一サイトの比較を提供します。
シナリオに適した比較の選択
- 厳格な権限境界に
isSameOrigin()を使用する: クロスウィンドウのpostMessageコマンド(埋め込みチェックアウトや認証ポップアップなど)を処理する場合や、認証情報を添付するかどうかを決定する場合は、(scheme, host, port)の完全一致を要求します。異なるサブドメイン(https://blog.example.comとhttps://app.example.com)であっても、異なるオリジンであり、拒否する必要があります。これにより、1 つのサブドメインの脆弱性が別のサブドメインを制御できなくなります。 - サブドメイン間のナビゲーションとリンクの分類に
isSameSite()を使用する: ログイン後のリダイレクト URL(?returnTo=https://billing.example.co.uk/orders)を検証する場合や、リンクを内部リンクと外部リンクに分類する場合に、同じスキームと登録可能なドメインを共有するサブドメインまたはポートを許可します。isSameSite()は、http:スキームのダウングレードと、共有パブリック サフィックス(.co.uk、.github.io)上の兄弟テナントの両方を拒否します。 - 不透明なコンテキストまたはサンドボックス化されたコンテキストには
origin.opaqueとOrigin.from(event)を使用する: リダイレクト(origin.opaque === true)のdata:URL を拒否するか、シリアル化されたevent.originが"null"である特定の<iframe sandbox="allow-scripts">ウィジェットと通信するときに、Origin.from(event)を介して抽出された不透明なOriginを固定します。
1. 特権アクション(Origin.from() と isSameOrigin())の正確な送信元を確認する
Origin.from() を使用して Origin オブジェクトを抽出し、isSameOrigin() を使用して信頼できる Origin と比較します。
- 両側が
Originインスタンスである必要があります。isSameOrigin(other)とisSameSite(other)はOriginオブジェクトのみを受け入れます。未加工の文字列を渡すと、TypeErrorがスローされます。 - サポートされている
Origin.from()入力:- シリアル化された URL 文字列(例:
'https://app.example.com:443/path')。無効な URL 文字列(例:'example'、不透明なオリジンに対してevent.originによってシリアル化された'null'文字列)を指定すると、Origin.from()はTypeErrorをスローします。 - HTML 仕様でオリジン抽出ステップを定義するプラットフォーム オブジェクト:
Origin、URL、HTMLAnchorElement(<a>)、HTMLAreaElement(<area>)、同一オリジンのWindowまたはWorkerGlobalScope(Origin.from(self))、ブラウザからディスパッチされたMessageEventインスタンス(Origin.from(event))。 Location、WorkerLocation、Document、Request、Responseなどのオブジェクトは、オリジン抽出ステップを定義しません。Origin.from()に直接渡されるとTypeErrorをスローします。代わりにself(現在のコンテキストの場合)またはrequest.url/response.urlを渡します。
- シリアル化された URL 文字列(例:
- ブラウザからディスパッチされた インスタンスと合成
MessageEventインスタンスの両方を処理する: 実際のブラウザのpostMessageイベントには、Origin.from(event)によって抽出された内部オリジンが含まれます。一方、単体テストで作成された合成new MessageEvent('message', { origin })イベントは、event.origin文字列のみを入力し、Origin.from(event)でスローします。try...catch内でOrigin.from(event.origin)にフォールバックすると、無効な文字列や"null"文字列を拒否しながら、両方を適切に処理できます。
// Replace with your application's trusted origin(s)
const TRUSTED_ORIGIN = Origin.from('https://app.example.com');
function extractOrigin(candidate) {
try {
return Origin.from(candidate);
} catch (err) {
// Synthetic new MessageEvent('message', { origin }) objects in tests lack an
// internal browser origin; fall back to parsing candidate.origin if it is a URL.
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return Origin.from(candidate.origin);
}
throw err;
}
}
export function isTrustedSameOrigin(candidate) {
try {
return extractOrigin(candidate).isSameOrigin(TRUSTED_ORIGIN);
} catch {
// Rejects invalid URLs, "null" strings, or unsupported objects
return false;
}
}
window.addEventListener('message', (event) => {
if (!isTrustedSameOrigin(event)) return;
// Safe to process privileged commands from the verified origin
});
2. クロスサブドメインのリダイレクトとリンクを検証する(isSameSite())
同じ登録可能ドメインのサブドメイン(https://app.example.co.uk と https://billing.example.co.uk:8443 など)にわたるリダイレクト ターゲット(?returnTo= パラメータなど)または内部リンクを検証する場合は、isSameSite() を使用します。
isSameSite()はスキームフル Same-Site 比較を行います。https://sub.example.comとhttp://sub.example.comはスキームが異なるため、Same-Site ではありません。isSameSite()はブラウザに組み込まれたパブリック サフィックス リストを使用するため、共有パブリック サフィックス上の異なるテナント(https://tenant-a.github.ioとhttps://tenant-b.github.io、またはhttps://a.co.ukとhttps://b.co.ukなど)はfalseと評価されます。
// Replace with your application's primary origin
const APP_ORIGIN = Origin.from('https://app.example.co.uk');
export function isAllowedSameSiteUrl(candidateUrlOrElement) {
try {
return extractOrigin(candidateUrlOrElement).isSameSite(APP_ORIGIN);
} catch {
return false;
}
}
// true: same scheme (https) and same registrable domain (example.co.uk)
isAllowedSameSiteUrl('https://billing.example.co.uk:8443/checkout');
// false: scheme downgrade (http vs. https) is rejected by schemeful same-site
isAllowedSameSiteUrl('http://billing.example.co.uk/checkout');
3. 不透明なオリジン(origin.opaque と Origin.from(event))を区別して固定する
サンドボックス化された iframe(<iframe sandbox="allow-scripts">)、data: URL、new Origin() は、不透明なオリジン(origin.opaque === true)を生成します。文字列のシリアル化は常に "null" ですが、Origin.from(event) はブラウザからディスパッチされた MessageEvent から送信者の実際の基盤となる不透明なオリジンを抽出します。
- 同じサンドボックス化された iframe から送信された 2 つのメッセージは、互いに
isSameOrigin()であるOriginオブジェクトを生成します(true)。 - 異なるサンドボックス化された iframe(または個別の
Origin.from('data:...')/new Origin()呼び出し)から送信されたメッセージは、isSameOrigin()をfalseと評価する個別の不透明なオリジンを生成し、無関係な不透明なコンテキスト間で"null" === "null"のなりすましを防ぎます。 new Origin()は、自身以外とは一致しない、新しい一意の不透明なオリジンを作成します。これは、初期化時に想定されるピアオリジンが 1 回固定される前に、デフォルトのすべて拒否のセンチネルとして役立ちます。
// Initialize with a unique opaque sentinel that matches no other origin
let pinnedSandboxOrigin = new Origin();
let isSandboxPinned = false;
export function handleSandboxedWidgetMessage(event, expectedWidgetWindow) {
try {
const senderOrigin = Origin.from(event);
// Pin the opaque origin only once on initialization from the expected sandboxed iframe
if (!isSandboxPinned && event.source === expectedWidgetWindow && event.data?.type === 'init') {
pinnedSandboxOrigin = senderOrigin;
isSandboxPinned = true;
}
// Subsequent messages from the SAME sandboxed iframe match pinnedSandboxOrigin;
// messages from any other sandboxed iframe (also event.origin === "null") return false.
return senderOrigin.isSameOrigin(pinnedSandboxOrigin);
} catch {
return false;
}
}
ベスト プラクティス
isSameOrigin(other)またはisSameSite(other)を呼び出す前に、Origin.from()を介して比較の両側をOriginインスタンスに変換してください。- 推奨事項: 不透明なオリジンの ID を保持する必要がある場合は、ブラウザからディスパッチされた
MessageEventをOrigin.from(event)に直接渡します。また、現在の実行コンテキストのOriginを取得するには、LocationやDocument(TypeErrorをスローする)ではなくOrigin.from(self)を使用します。 - 信頼できない入力やランタイム入力に対する
Origin.from()呼び出しをtry...catchブロックでラップする。これにより、無効な URL、"null"文字列、サポートされていないオブジェクトは、キャッチされないTypeErrorをスローするのではなく、安全にfalseを返すようになります。 "null"(origin !== 'null')を最初に拒否せずに、2 つのランタイムevent.origin文字列またはurl.origin文字列を直接比較しないでください。2 つの無関係な不透明なオリジンが両方とも"null"にシリアル化されるためです。startsWith()、includes()、endsWith()、または単純な.split('.')ホスト名スライスを使用して、オリジンまたはサイトを検証しないでください。
フォールバック戦略
Baseline ターゲットに Origin インターフェースをまだサポートしていないブラウザが含まれている場合は、'Origin' in globalThis を機能検出して、new URL() で URL を解析するようにフォールバックします。
- 同一オリジン フォールバック(タプルオリジン): URL 文字列を抽出(
MessageEventオブジェクトにはevent.origin、リンク要素にはcandidate.hrefを使用)し、候補 URL と信頼できる URL の両方をnew URL()で解析し、"null"の不透明なオリジン(candidateUrl.origin !== 'null')を拒否して、candidateUrl.origin === trustedUrl.originを比較します。不透明なオリジンは、'https://app.example.com'のようなタプルオリジンと同じオリジンになることはないため、'null'をすぐに拒否するとOrigin.isSameOrigin()と一致します。 - Same-site フォールバック(注意点):
URLインターフェースは、ブラウザの Public Suffix List を公開しません。Originのないブラウザでは、管理している明示的な既知のプライベート登録可能ドメインに対してのみサフィックスの一致を実行するか(protocolと、完全一致またはドット付きのhostnameの一致の両方を検証)、管理されている Public Suffix List ライブラリを使用します。 - 不透明 / サンドボックス化された iframe のフォールバック:
Originのないブラウザは、MessageEventで個別の不透明なオリジン ID を公開しません(event.originは常に"null")。既知のサンドボックス化された<iframe sandbox="allow-scripts">からのメッセージを意図的に受け入れる場合は、event.originを比較するのではなく、event.source === expectedWidgetWindowを確認します(または、初期化時に iframe に転送された専用のMessageChannelMessagePortを介して通信します)。
// Replace with your trusted reference URL and known registrable domain
const TRUSTED_URL = 'https://app.example.com';
const TRUSTED_SITE_DOMAIN = 'example.com';
function extractUrlString(candidate) {
if (typeof candidate === 'string') return candidate;
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return candidate.origin;
}
if (candidate === globalThis) {
return globalThis.location?.href ?? '';
}
return candidate?.href ?? '';
}
function extractOrigin(candidate) {
try {
return Origin.from(candidate);
} catch (err) {
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return Origin.from(candidate.origin);
}
throw err;
}
}
export function checkSameOriginWithFallback(candidate) {
try {
if ('Origin' in globalThis) {
return extractOrigin(candidate).isSameOrigin(Origin.from(TRUSTED_URL));
}
const candidateUrl = new URL(extractUrlString(candidate));
const trustedUrl = new URL(TRUSTED_URL);
return candidateUrl.origin !== 'null' && candidateUrl.origin === trustedUrl.origin;
} catch {
return false;
}
}
export function checkSameSiteWithFallback(candidate) {
try {
if ('Origin' in globalThis) {
return extractOrigin(candidate).isSameSite(Origin.from(TRUSTED_URL));
}
const candidateUrl = new URL(extractUrlString(candidate));
const trustedUrl = new URL(TRUSTED_URL);
return (
candidateUrl.origin !== 'null' &&
candidateUrl.protocol === trustedUrl.protocol &&
(candidateUrl.hostname === TRUSTED_SITE_DOMAIN ||
candidateUrl.hostname.endsWith(`.${TRUSTED_SITE_DOMAIN}`))
);
} catch {
return false;
}
}