24 квітня 2022 р.

Міжвіконна комунікація

Політика “Одного походження” (Same Origin), іншими словами один й той самий сайт, вона обмежує доступ вікон і фреймів один до одного.

Ідея полягає в тому, що якщо користувач має дві відкриті сторінки: одну з john-smith.com, а іншу – gmail.com, тоді він не хоче, щоб скрипт з john-smith.com читав пошту з gmail.com. Отже, мета політики “Одного походження” – захистити користувачів від крадіжки даних.

Політика "Одного походження" (Same Origin)

Кажуть, що дві URL-адреси мають “одне походження”, якщо вони мають однаковий протокол, домен і порт.

Усі ці URL-адреси мають одне походження:

  • http://site.com
  • http://site.com/
  • http://site.com/my/page.html

А ці ні:

  • http://www.site.com (інший домен: www. має значення)
  • http://site.org (інший домен: .org має значення)
  • https://site.com (інший протокол: https)
  • http://site.com:8080 (інший порт: 8080)

У політиці “Одного походження” зазначено, що:

  • якщо ми маємо посилання на інше вікно, створене за допомогою window.open або вікно всередині <iframe>, і воно має те саме походження, то ми маємо повний доступ до нього.
  • в іншому випадку, якщо походження відрізняється, ми не можемо отримати доступ до вмісту цього вікна: змінних, документа, будь-чого. Єдиним винятком є location: ми можемо змінити його (таким чином перенаправити користувача). Але ми не можемо читати це місцезнаходження (тому ми не можемо побачити, де зараз перебуває користувач, тому немає витоку інформації).

iframe на практиці

Кожен <iframe> містить окреме вбудоване вікно з окремими об’єктами document та window.

Ми можемо отримати до них доступ за допомогою властивостей:

  • iframe.contentWindow, щоб отримати вікно всередині <iframe>.
  • iframe.contentDocument, щоб отримати документ всередині <iframe>, скорочення від iframe.contentWindow.document.

Коли ми отримуємо доступ до чогось у вбудованому вікні, браузер перевіряє, чи має iframe те саме походження. Якщо це не так, то доступ забороняється (зміни location є винятком, це дозволено).

Наприклад, давайте спробуємо прочитати та записати щось в <iframe> з іншим походженням:

<iframe src="https://example.com" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // ми можемо отримати посилання на внутрішнє вікно
    let iframeWindow = iframe.contentWindow; // OK
    try {
      // ...але не до документа всередині нього
      let doc = iframe.contentDocument; // ПОМИЛКА
    } catch(e) {
      alert(e); // Security Error (інше походження)
    }

    // ми також не можемо ПРОЧИТАТИ URL-адресу сторінки в iframe
    try {
      // Не вдається прочитати URL-адресу з об’єкта Location
      let href = iframe.contentWindow.location.href; // ПОМИЛКА
    } catch(e) {
      alert(e); // Security Error
    }

    // ...ми можемо ЗМІНИТИ location (і таким чином завантажувати щось інше в iframe)!
    iframe.contentWindow.location = '/'; // OK

    iframe.onload = null; // очищаємо обробник, щоб не запускати його після зміни location
  };
</script>

Код вище показує помилки для будь-яких операцій, крім:

  • Отримання посилання на внутрішнє вікно iframe.contentWindow – це дозволено.
  • Зміни location.

На противагу цьому, якщо <iframe> має те саме походження, ми можемо робити з ним що завгодно:

<!-- iframe з того ж сайту -->
<iframe src="/" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // робіть будь-що
    iframe.contentDocument.body.prepend("Привіт, світ!");
  };
</script>
iframe.onload vs iframe.contentWindow.onload

Подія iframe.onload (у тегу <iframe>) по суті така ж, як iframe.contentWindow.onload (у вбудованому об’єкті вікна). Вона запускається, коли вбудоване вікно повністю завантажується зі всіма ресурсами.

…Але ми не можемо отримати доступ до iframe.contentWindow.onload для iframe з іншим походженням, тому використовуємо iframe.onload.

Вікна на субдоменах: document.domain

За визначенням, дві URL-адреси з різними доменами мають різне походження.

Але якщо вікна спільно використовують один домен другого рівня, наприклад, john.site.com, peter.site.com і site.com (тобто їхнім спільним доменом другого рівня є site.com), ми можемо змусити браузер ігнорувати цю різницю, і сприймати їх як сайти “одного походження”, це значно полегшує комунікацію між вікнами.

Щоб це спрацювало, кожне таке вікно має запустити код:

document.domain = 'site.com';

Це все. Тепер вони можуть взаємодіяти без обмежень. Знову ж таки, це можливо лише для сторінок з однаковим доменом другого рівня.

Не підтримується, але все ще працює

Властивість document.domain зараз у процесі вилучення зі специфікації. Обмін повідомленнями між вікнами (пояснення невдовзі нижче) є запропонованою заміною.

Тим не менш, наразі всі браузери підтримують її. І підтримку буде збережено на майбутнє, щоб не зламати старий код, який покладається на document.domain.

Iframe: підводний камінь при роботі з document

Коли iframe має однакове подходження походження з оригінальним сайтом, і ми можемо отримати доступ до його document, з’являється підводний камінь, про який важливо знати.

Після створення iframe одразу має document. Але цей document буде іншим після того, як закінчеться завантаження iframe!

Тому, якщо ми негайно зробимо щось із документом, зміни, ймовірно, буде втрачено.

Ось подивіться:

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;
  iframe.onload = function() {
    let newDoc = iframe.contentDocument;
    // завантажений документ не збігається з початковим!
    alert(oldDoc == newDoc); // false
  };
</script>

Нам не слід працювати з document ще не завантаженого iframe, тому що це неправильний document. Якщо ми додамо до нього обробники подій, вони будуть проігноровані.

Як визначити момент, коли з document вже можно працювати?

Правильний document вже точно знаходиться на місці, коли запускається iframe.onload. Але він запускається лише тоді, коли завантажується весь iframe з усіма ресурсами.

Ми можемо спробувати визначити цей момент раніше за допомогою перевірок у setInterval:

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;

  // кожні 100 мс перевіряємо, чи є document новим
  let timer = setInterval(() => {
    let newDoc = iframe.contentDocument;
    if (newDoc == oldDoc) return;

    alert("Новий документ тут!");

    clearInterval(timer); // скасуємо setInterval, він більше не потрібен
  }, 100);
</script>

Колекція: window.frames

Альтернативний спосіб отримати об’єкт вікна для <iframe> – це отримати його з іменованої колекції window.frames:

  • За номером: window.frames[0] – об’єкт вікна для першого фрейму в документі.
  • За назвою: window.frames.iframeName – об’єкт вікна для фрейму з name="iframeName".

Наприклад:

<iframe src="/" style="height:80px" name="win" id="iframe"></iframe>

<script>
  alert(iframe.contentWindow == frames[0]); // true
  alert(iframe.contentWindow == frames.win); // true
</script>

Усередині iframe можуть бути інші iframe. Відповідні об’єкти window утворюють ієрархію.

Навігаційні посилання:

  • window.frames – колекція дочірніх вікон (для вкладених фреймів).
  • window.parent – посилання на “батьківське” (зовнішнє) вікно.
  • window.top – посилання на найвище батьківське вікно.

Наприклад:

window.frames[0].parent === window; // true

Ми можемо використовувати властивість top, щоб перевірити, чи відкритий поточний документ у фреймі чи ні:

if (window == top) { // current window == window.top?
  alert('Скрипт знаходиться у батьківському вікні, а не у фреймі');
} else {
  alert('Скрипт виконується у фреймі!');
}

Атрибут iframe “sandbox”

Атрибут sandbox дозволяє заборонити певні дії всередині <iframe>, щоб запобігти виконанню коду, якому ми не до кінця довіряємо. Атрибут закриває iframe у “пісочниці”, розглядаючи його як iframe іншого походження та/або застосовуючи інші обмеження.

До <iframe sandbox src="..."> з атрибутом sandbox застосовується “типовий набір” певних обмежень. Але їх можна послабити, для цього потрібно окремо задати список обмежень, які не слід застосовувати. Назви ціх обмежень потрібно розділити пробілами і записати як значення атрибута sandbox, наприклад: <iframe sandbox="allow-forms allow-popups">.

Іншими словами, порожній атрибут "sandbox" накладає найсуворіші обмеження, але ми можемо помістити розділений пробілами список тих, які ми хочемо зняти.

Ось список обмежень:

allow-same-origin
Типово атрибут "sandbox" нав’язує політику “іншого походження” для iframe. Це змушує браузер сприймати iframe, як iframe з іншим походженням, навіть якщо його src вказує на той самий сайт. Це застосовує усі неявні обмеженнями для скриптів. Цей параметр вимикає цю функцію.
allow-top-navigation
Дозволяє iframe змінити parent.location.
allow-forms
Дозволяє надсилати форми з iframe.
allow-scripts
Дозволяє запускати скрипти з iframe.
allow-popups
Дозволяє window.open спливаючі вікна з iframe

Дивіться посібник для отримання додаткової інформації.

Наведений нижче приклад демонструє iframe із ізольованим середовищем із набором обмежень за замовчуванням: <iframe sandbox src="...">. Він має певний JavaScript і форму.

Зверніть увагу, що нічого не працює. Отже, типовий набір обмежень дійсно суворий:

Результат
index.html
sandboxed.html
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <div>Наведений нижче iframe має атрибут <code>sandbox</code>.</div>

  <iframe sandbox src="sandboxed.html" style="height:60px;width:90%"></iframe>

</body>
</html>
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <button onclick="alert(123)">Клікніть, щоб запустити скрипт (не спрацює)</button>

  <form action="http://google.com">
    <input type="text">
    <input type="submit" value="Надіслати (не спрацює)">
  </form>

</body>
</html>
Будь ласка, зверніть увагу:

Мета атрибута "sandbox" – лише додати більше обмежень. Він не може їх видалити. Зокрема, він не може послабити стандартні обмеження щодо походження, якщо iframe насправді має інше походження.

Обмін повідомленнями між вікнами

Інтерфейс postMessage дозволяє вікнам спілкуватися один з одним незалежно від того, яке в них походження.

Отже, це спосіб обійти політику “Одного походження”. Це дозволяє вікну з john-smith.com спілкуватися з gmail.com та обмінюватися інформацією, але лише якщо вони обидва згодні та викликають відповідні функції JavaScript. Це робить його безпечним для користувачів.

Інтерфейс складається з двох частин.

postMessage

Вікно, яке хоче надіслати повідомлення, викликає метод postMessage вікна отримання. Іншими словами, якщо ми хочемо надіслати повідомлення до win, ми повинні викликати win.postMessage(data, targetOrigin).

Аргументи:

data
Дані для відправки. Можуть бути будь-яким об’єктом, дані клонуються за допомогою “алгоритму структурованої серіалізації”. IE підтримує лише рядки, тому ми повинні застосувати JSON.stringify для складних об’єктів для підтримки цього браузера.
targetOrigin
Вказує джерело для цільового вікна, щоб повідомлення отримувало лише вікно з даного джерела.

targetOrigin є заходом безпеки. Пам’ятайте, якщо цільове вікно походить з іншого джерела, ми не можемо прочитати його location у вікні відправника. Тому ми не можемо бути впевнені, який сайт зараз відкритий у передбачуваному вікні: користувач міг би піти, а вікно відправника не має про це поняття.

targetOrigin гарантує, що iframe отримає дані, лише якщо він все ще знаходиться на потрібному сайті. Це важливо, коли дані є чутливими або конфіденційними.

Наприклад, тут win отримає повідомлення, лише якщо в ньому document з адресою http://example.com:

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("повідомлення", "http://example.com");
</script>

Якщо ми не хочемо цієї перевірки, ми можемо встановити для targetOrigin значення *.

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("повідомлення", "*");
</script>

onmessage

Щоб отримати повідомлення, вікно отримувач має мати обробник події message. Він запускається, коли викликається postMessage (і перевірка targetOrigin успішна).

Об’єкт події має спеціальні властивості:

data
Дані від postMessage.
origin
Походження відправника, наприклад http://javascript.info.
source
Посилання на вікно відправника. Ми можемо негайно повернути source.postMessage(...), якщо хочемо.

Щоб призначити цей обробник, ми повинні використовувати addEventListener, короткий синтаксис window.onmessage не працює.

Ось приклад:

window.addEventListener("message", function(event) {
  if (event.origin != 'http://javascript.info') {
    // щось із невідомого домену, проігноруємо це
    return;
  }

  alert( "отримано: " + event.data );

  // можна надіслати повідомлення назад за допомогою event.source.postMessage(...)
});

Повний приклад:

Результат
iframe.html
index.html
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  Отримання iframe.
  <script>
    window.addEventListener('message', function(event) {
      alert(`Отримано ${event.data} з ${event.origin}`);
    });
  </script>

</body>
</html>
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>

  <form id="form">
    <input type="text" placeholder="Введіть повідомлення" name="message">
    <input type="submit" value="Клікніть, щоб надіслати">
  </form>

  <iframe src="iframe.html" id="iframe" style="display:block;height:60px"></iframe>

  <script>
    form.onsubmit = function() {
      iframe.contentWindow.postMessage(this.message.value, '*');
      return false;
    };
  </script>

</body>
</html>

Підсумки

Щоб викликати методи та отримати доступ до вмісту іншого вікна, ми повинні спочатку мати посилання на нього.

Для спливаючих вікон у нас є такі посилання:

  • З вікна відкриття: window.open – відкриває нове вікно та повертає посилання на нього,
  • Зі спливаючого вікна: window.opener – це посилання на основне вікно зі спливаючого вікна.

Для iframes ми можемо отримати доступ до батьківських/дочірніх вікон за допомогою:

  • window.frames – набір вкладених об’єктів вікна,
  • window.parent, window.top посилання на батьківське та верхнє вікна,
  • iframe.contentWindow – вікно всередині тегу <iframe>.

Якщо вікна мають однакове походження (хост, порт, протокол), то вони можуть робити між собою все, що захочуть.

В іншому випадку можливі лише такі дії:

  • Змінити location іншого вікна (доступ лише для запису).
  • Надіслати на нього повідомлення.

Винятки:

  • Вікна, які використовують той самий домен другого рівня: a.site.com та b.site.com. Налаштування document.domain='site.com' в обох переведе їх у стан “одного походження”.
  • Якщо iframe має атрибут sandbox, він примусово переводиться в стан “іншого походження”, якщо у значенні атрибута не вказано allow-same-origin. Це можна використовувати для запуску коду, якому ми не до кінця довіряємо, в iframes з того самого сайту.

Інтерфейс postMessage дозволяє надсилати повідомлення двом вікнам з будь-яким походженням:

  1. Відправник викликає targetWin.postMessage(data, targetOrigin).

  2. Якщо значення у targetOrigin не '*', тоді браузер перевіряє, чи має вікно targetWin джерело targetOrigin.

  3. Якщо це так, то targetWin ініціює подію message зі спеціальними властивостями:

    • origin – походження вікна відправника (наприклад, http://my.site.com)
    • source – посилання на вікно відправника.
    • data – дані, можуть бути об’єктом скрізь, крім IE (в IE тільки рядок).

    Ми повинні використовувати addEventListener, щоб встановити обробник для цієї події всередині вікна отримувача.

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)