ウェブにおけるオリジンとサイトを比較して検証する

ウェブ機能: オリジン

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 を渡します。
  • ブラウザからディスパッチされた インスタンスと合成 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
});

同じ登録可能ドメインのサブドメイン(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 に転送された専用の MessageChannel MessagePort を介して通信します)。
// 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;
  }
}