Сравнение и проверка источников и сайтов

Веб-функции: Источник

Сравнение event.origin с фиксированным строковым литералом с нормализованным портом (например, event.origin === 'https://app.example.com') подходит для простых белых списков. Однако сравнение строк вручную и регулярные выражения не работают при проверке произвольных URL, элементов link в DOM, переадресаций между субдоменами и сообщений в изолированной среде.

  • Подмена префикса и подстроки. Проверка url.startsWith('https://app.example.com') обходится с помощью 'https://app.example.com.attacker.example'.
  • Ненормализованные URL. Полные URL с путями, строками запроса или явными портами по умолчанию ('https://app.example.com:443/path') не соответствуют 'https://app.example.com' при строковом равенстве без предварительного синтаксического анализа и нормализации.
  • Ошибки в списке общедоступных суффиксов. Разделение имен хостов по символу . для проверки отношений между сайтами не работает для общедоступных суффиксов с несколькими метками (например, .co.uk или доменов общего хостинга, таких как .github.io) и игнорирует несоответствия схем (http: и https:).
  • Конфликты непрозрачных источников "null". Изолированные iframe-объекты (<iframe sandbox="allow-scripts">) и URL data: сериализуют event.origin в строку "null". Сравнение двух сериализованных источников среды выполнения с помощью originA === originB ошибочно приравнивает два несвязанных непрозрачных контекста ("null" === "null").

Origin API (Origin.from(), isSameOrigin(), isSameSite() и opaque) предоставляет структурированные, соответствующие спецификации сравнения источников и сайтов с одинаковой схемой, поддерживаемые синтаксическим анализатором URL браузера и списком Public Suffix List.

Как выбрать подходящее сравнение для вашего сценария

  • Используйте isSameOrigin() для строгих границ привилегий. Требуйте точного совпадения (scheme, host, port) при обработке межконтекстных команд postMessage (например, встроенного всплывающего окна оплаты или аутентификации) или при принятии решения о том, следует ли прикреплять учетные данные. Даже разные субдомены (https://blog.example.com и https://app.example.com) считаются разными источниками и должны отклоняться, чтобы уязвимость в одном субдомене не позволяла управлять другим.
  • Используйте isSameSite() для навигации между субдоменами и классификации ссылок. Разрешите любой субдомен или порт, использующий ту же схему и регистрируемый домен, при проверке URL переадресации после входа (?returnTo=https://billing.example.co.uk/orders) или классификации ссылок как внутренних или внешних. isSameSite() отклоняет как понижение схемы http:, так и дочерние клиенты в общих общедоступных суффиксах (.co.uk, .github.io).
  • Используйте origin.opaque и Origin.from(event) для непрозрачных или изолированных контекстов. Отклоняйте URL data: для переадресации (origin.opaque === true) или закрепляйте непрозрачный Origin, извлеченный с помощью Origin.from(event), при взаимодействии с определенным виджетом <iframe sandbox="allow-scripts">, сериализованный event.origin которого – "null".

1. Проверка точных источников для привилегированных действий (Origin.from() и isSameOrigin())

Используйте Origin.from(), чтобы извлечь объект Origin и сравнить его с доверенным Origin с помощью isSameOrigin():

  • Обе стороны должны быть экземплярами Origin. isSameOrigin(other) и isSameSite(other) принимают только объект Origin. Если передать необработанную строку, возникнет ошибка TypeError.
  • Поддерживаемые входные данные Origin.from():
    • Сериализованные строки URL (например, 'https://app.example.com:443/path'). Обратите внимание, что любая недопустимая строка URL, например 'example' или строка 'null', сериализованная event.origin для непрозрачных источников, приводит к тому, что Origin.from() выдает TypeError.
    • Объекты платформы, которые определяют шаги извлечения источника в спецификации HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window или WorkerGlobalScope (Origin.from(self)) того же источника и экземпляры MessageEvent, отправленные браузером (Origin.from(event)).
    • Объекты Location, WorkerLocation, Document, Request и Response не определяют шаги извлечения источника и вызывают ошибку TypeError, если передаются напрямую в Origin.from(). Вместо этого передавайте 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
});

При проверке целей переадресации (например, параметров ?returnTo=) или внутренних ссылок между субдоменами одного регистрируемого домена (например, https://app.example.co.uk и https://billing.example.co.uk:8443) используйте isSameSite():

  • isSameSite() выполняет сравнение одинаковых сайтов с учетом схемы: https://sub.example.com и http://sub.example.com не считаются одинаковыми сайтами, потому что у них разные схемы.
  • 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">), URL data: и new Origin() создают непрозрачные источники (origin.opaque === true). Хотя их сериализация строк всегда имеет вид "null", Origin.from(event) извлекает фактический базовый непрозрачный источник отправителя из отправленного браузером MessageEvent:

  • Два сообщения, отправленные из одного и того же изолированного iframe, возвращают объекты Origin, которые isSameOrigin() друг с другом (true).
  • Сообщения, отправленные из разных изолированных iframe или отдельных вызовов Origin.from('data:...')/new Origin(), дают разные непрозрачные источники, которые оценивают isSameOrigin() как false, предотвращая спуфинг "null" === "null" в несвязанных непрозрачных контекстах.
  • new Origin() создает новый уникальный непрозрачный источник, который не соответствует ничему, кроме самого себя. Это полезно в качестве стандартного запрещающего все сторожа, прежде чем ожидаемый одноранговый источник будет закреплен один раз при инициализации.
// 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;
  }
}

Рекомендации

  • РЕКОМЕНДУЕТСЯ преобразовывать обе стороны сравнения в экземпляры Origin с помощью Origin.from() перед вызовом isSameOrigin(other) или isSameSite(other).
  • РЕКОМЕНДУЕТСЯ передавать отправленный браузером MessageEvent напрямую в Origin.from(event), когда нужно сохранить идентификатор скрытого источника, и использовать Origin.from(self) (а не Location или Document, которые вызывают TypeError), чтобы получить Origin текущего контекста выполнения.
  • РЕКОМЕНДУЕТСЯ оборачивать вызовы Origin.from() для ненадежных или динамических входных данных в блок try...catch, чтобы недопустимые URL, строки "null" или неподдерживаемые объекты безопасно возвращали false вместо того, чтобы вызывать необработанную ошибку TypeError.
  • НЕ СРАВНИВАЙТЕ две строки event.origin или url.origin напрямую, не отклонив сначала "null" (origin !== 'null'), поскольку два несвязанных скрытых источника сериализуются в "null".
  • НЕ проверяйте источники или сайты с помощью startsWith(), includes(), endsWith() или простого разделения имени хоста .split('.').

Резервная стратегия

Если ваша целевая аудитория Baseline включает браузеры, которые ещё не поддерживают интерфейс Origin, определите функцию 'Origin' in globalThis и вернитесь к синтаксическому анализу URL с помощью new URL():

  • Резервный вариант для одного источника (источники кортежей). Извлеките строку URL (используя event.origin для объектов MessageEvent или candidate.href для элементов ссылок), проанализируйте как кандидат, так и доверенные URL с помощью new URL(), отклоните непрозрачные источники "null" (candidateUrl.origin !== 'null') и сравните candidateUrl.origin === trustedUrl.origin. Поскольку непрозрачный источник никогда не совпадает с источником-кортежем, например 'https://app.example.com', немедленное отклонение 'null' соответствует Origin.isSameOrigin().
  • Резервный вариант для одного сайта (предупреждение). Интерфейс URL не предоставляет доступ к списку публичных суффиксов браузера. В браузерах без Origin выполняйте сопоставление суффиксов только с явным, известным частным регистрируемым доменом, который вы контролируете (проверяя совпадение как protocol, так и точное или с префиксом в виде точки hostname), или используйте поддерживаемую библиотеку Public Suffix List.
  • Резервный вариант для непрозрачных изолированных фреймов iframe. Браузеры без Origin не предоставляют отдельные идентификаторы непрозрачных источников в MessageEvent (event.origin всегда имеет значение "null"). Если вы намеренно принимаете сообщения из известного изолированного фрейма <iframe sandbox="allow-scripts">, проверяйте event.source === expectedWidgetWindow (или обменивайтесь данными через выделенный MessageChannel MessagePort, переданный во фрейм iframe при инициализации), а не сравнивайте event.origin.
// 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;
  }
}