Пошук уроків, статей та іншого контенту
Налаштуєте безпечну взаємодію з новими вікнами через rel="noopener", postMessage та перевірку джерела повідомлень.
Нове вікно або вкладка може бути відкритим через:
посилання з target="_blank";
window.open();
зовнішній сервіс, який повертає користувача у ваш застосунок.
У браузері відкривається зв’язок між вікнами через властивість window.opener. Відкрита сторінка може використати його, щоб:
змінити адресу батьківського вікна;
переспрямувати користувача на фішингову сторінку;
надсилати повідомлення батьківському вікну;
спробувати виконати логіку, яка помилково довіряє джерелу повідомлення.
Ця проблема відома як reverse tabnabbing. Навіть якщо користувач відкрив нову вкладку самостійно, початкова сторінка може залишатися доступною через window.opener.
rel="noopener" для посиланьДля посилань, які відкривають нове вікно, використовуйте rel="noopener":
<a
href="https://external.example/"
target="_blank"
rel="noopener"
>
Відкрити зовнішній сервіс
</a>Тоді відкрита сторінка не отримує доступу до window.opener.
Часто також додають noreferrer:
<a
href="https://external.example/"
target="_blank"
rel="noopener noreferrer"
>
Відкрити зовнішній сервіс
</a>noreferrer додатково забороняє передавання заголовка Referer. Це може бути корисно, якщо URL поточної сторінки містить чутливі параметри.
noopener і noreferrer не захищають дані, які ви добровільно передали в URL. Наприклад, токен у такому посиланні все одно буде доступним одержувачу:
<a href="https://external.example/?token=secret">
Небезпечне посилання
</a>Секрети не слід передавати через URL.
noopener у window.open()Для програмного відкриття вікна можна передати параметри в третьому аргументі window.open():
const popup = window.open(
'https://external.example/',
'_blank',
'noopener,noreferrer'
);У такому режимі нове вікно не повинно використовувати window.opener.
Не покладайтеся на перевірку лише результату window.open(). Браузер може повернути null, якщо відкриття заборонене блокувальником спливаючих вікон або політикою браузера.
const popup = window.open(
'https://external.example/',
'_blank',
'noopener,noreferrer'
);
if (!popup) {
console.error('Вікно не було відкрито');
}postMessageВікна різних джерел не можуть напряму читати DOM або JavaScript-стан одне одного через Same-Origin Policy.
Наприклад, сторінка на:
https://app.exampleне може напряму звертатися до DOM сторінки на:
https://payments.exampleДля контрольованого обміну повідомленнями використовується postMessage().
Базовий виклик має такий вигляд:
targetWindow.postMessage(message, targetOrigin);targetWindow — посилання на інше вікно;
message — дані, які потрібно передати;
targetOrigin — очікуване джерело одержувача.
Зазвичай повідомлення передаються у форматі об’єкта:
popup.postMessage(
{
type: 'payment:created',
paymentId: 'pay_123'
},
'https://payments.example'
);Браузер серіалізує повідомлення за допомогою structured clone algorithm. Можна передавати об’єкти, масиви, рядки, числа та деякі інші структуровані значення. Функції та DOM-вузли передавати не можна.
*Такий виклик дозволяє браузеру доставити повідомлення будь-якому джерелу:
popup.postMessage(
{ type: 'auth:success', token: accessToken },
'*'
);Якщо вікно буде перенаправлене на інший сайт або його URL буде підмінено, повідомлення може отримати вже не той код, якому ви довіряли.
Використовуйте точне джерело:
popup.postMessage(
{ type: 'payment:created', paymentId: 'pay_123' },
'https://payments.example'
);Значення targetOrigin — це origin, а не повна URL-адреса:
https://payments.exampleВін включає:
протокол;
домен;
порт, якщо він нестандартний.
Шлях, наприклад /callback, до targetOrigin не входить.
Для обробки повідомлень використовується подія message:
window.addEventListener('message', (event) => {
console.log(event.data);
});Обробник повинен перевіряти щонайменше:
event.origin;
event.source;
структуру та тип отриманих даних.
event.originevent.origin показує origin вікна, яке відправило повідомлення.
const TRUSTED_ORIGIN = 'https://payments.example';
window.addEventListener('message', (event) => {
if (event.origin !== TRUSTED_ORIGIN) {
return;
}
console.log('Повідомлення від довіреного джерела:', event.data);
});Не перевіряйте origin через частковий збіг:
// Небезпечно
if (event.origin.includes('example.com')) {
// Зловмисник може використати https://example.com.attacker.test
}Також небезпечно перевіряти лише початок рядка:
// Небезпечно
if (event.origin.startsWith('https://app.example')) {
// https://app.example.attacker.test також пройде перевірку
}Порівнюйте origin повністю.
event.sourceНавіть довірений origin може містити кілька різних вікон. Якщо ви очікуєте повідомлення від конкретного popup, перевірте посилання на нього:
const popup = window.open('/popup.html', 'settingsPopup');
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) {
return;
}
if (event.source !== popup) {
return;
}
console.log('Повідомлення від потрібного popup');
});Перевірка event.source особливо важлива, коли одночасно відкрито кілька вікон одного origin.
Нижче наведено приклад для двох сторінок одного застосунку:
index.html — батьківське вікно;
popup.html — нове вікно.
Для тестування відкрийте файли через локальний HTTP-сервер, а не безпосередньо через file://. Наприклад, origin для file:// може бути null, що змінює правила перевірки.
index.html<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Батьківське вікно</title>
</head>
<body>
<button id="openPopup" type="button">
Відкрити popup
</button>
<button id="requestProfile" type="button" disabled>
Запитати профіль
</button>
<pre id="output"></pre>
<script>
const APP_ORIGIN = window.location.origin;
const output = document.querySelector('#output');
const openButton = document.querySelector('#openPopup');
const profileButton = document.querySelector('#requestProfile');
let popup = null;
function print(value) {
output.textContent += `${value}\n`;
}
function sendToPopup(message) {
if (!popup || popup.closed) {
print('Popup недоступний');
return;
}
popup.postMessage(message, APP_ORIGIN);
}
window.addEventListener('message', (event) => {
// Приймаємо повідомлення лише від нашого origin.
if (event.origin !== APP_ORIGIN) {
return;
}
// Повідомлення має прийти саме від відкритого popup.
if (event.source !== popup) {
return;
}
const message = event.data;
// Перевіряємо форму повідомлення до використання даних.
if (!message || typeof message.type !== 'string') {
return;
}
if (message.type === 'popup:ready') {
print('Popup готовий');
sendToPopup({
type: 'popup:init',
payload: {
language: 'uk',
features: ['profile']
}
});
profileButton.disabled = false;
return;
}
if (message.type === 'profile:result') {
if (
!message.payload ||
typeof message.payload.name !== 'string'
) {
print('Некоректні дані профілю');
return;
}
print(`Профіль: ${message.payload.name}`);
}
});
openButton.addEventListener('click', () => {
popup = window.open(
'popup.html',
'profilePopup',
'popup,width=480,height=360'
);
if (!popup) {
print('Браузер заблокував popup');
return;
}
print('Popup відкрито');
});
profileButton.addEventListener('click', () => {
sendToPopup({
type: 'profile:get'
});
});
</script>
</body>
</html>popup.html<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Popup</title>
</head>
<body>
<p id="status">Підключення...</p>
<script>
const APP_ORIGIN = window.location.origin;
const status = document.querySelector('#status');
function sendToParent(message) {
if (!window.opener || window.opener.closed) {
status.textContent = 'Батьківське вікно недоступне';
return;
}
window.opener.postMessage(message, APP_ORIGIN);
}
window.addEventListener('message', (event) => {
// Повідомлення повинні надходити з очікуваного origin.
if (event.origin !== APP_ORIGIN) {
return;
}
// У цьому прикладі джерелом має бути саме батьківське вікно.
if (event.source !== window.opener) {
return;
}
const message = event.data;
if (!message || typeof message.type !== 'string') {
return;
}
if (message.type === 'popup:init') {
if (
!message.payload ||
!Array.isArray(message.payload.features)
) {
return;
}
status.textContent = 'Popup ініціалізовано';
return;
}
if (message.type === 'profile:get') {
// У реальному застосунку дані тут надходили б із дозволеного джерела.
sendToParent({
type: 'profile:result',
payload: {
name: 'Олена Коваль'
}
});
}
});
// Обробник повідомлень уже встановлений до надсилання handshake.
sendToParent({
type: 'popup:ready'
});
</script>
</body>
</html>У цьому прикладі:
повідомлення мають поле type;
обидві сторони перевіряють event.origin;
обидві сторони перевіряють event.source;
отримані дані перевіряються перед використанням;
повідомлення надсилаються з точним targetOrigin.
noopener і postMessage — різні моделіЄ важлива практична особливість.
Якщо відкрити вікно з noopener, воно не має window.opener. Отже, такий код у новому вікні не працюватиме:
window.opener.postMessage(
{ type: 'ready' },
'https://app.example'
);Це очікувана властивість захисту: нове вікно не має зворотного посилання на батьківське.
Тому потрібно розділяти два випадки.
Якщо не потрібен обмін повідомленнями через window.opener, використовуйте:
window.open(
'https://external.example/',
'_blank',
'noopener,noreferrer'
);Якщо popup є частиною вашого застосунку і потрібна взаємодія через postMessage, можна зберегти посилання на opener, але:
відкривати лише контрольований origin;
перевіряти event.origin;
перевіряти event.source;
не передавати секрети без потреби;
перевіряти структуру кожного повідомлення;
не дозволяти popup завантажувати довільний зовнішній контент без окремої моделі безпеки.
postMessage не робить popup автоматично довіреним. Він лише надає механізм доставки даних. Довіру потрібно реалізувати перевірками в коді.
Перевірка event.origin захищає конкретне повідомлення, але не робить саме вікно постійно безпечним. Popup може змінити свою адресу. Якщо ви довіряли йому як сторінці https://app.example, а потім воно перейшло на інший origin, наступні повідомлення повинні бути відхилені перевіркою event.origin.
Не слід надсилати секретні дані одразу після window.open() лише тому, що вікно було відкрите з потрібною URL. Надійніший порядок:
відкрити вікно;
дочекатися handshake;
перевірити origin і source;
лише потім обмінюватися дозволеними даними;
перевіряти кожну наступну подію окремо.
MessageChannelДля складнішої взаємодії можна створити окремий канал повідомлень. Це дозволяє не використовувати глобальний window як єдиний канал після встановлення з’єднання.
Батьківське вікно передає один порт popup:
const channel = new MessageChannel();
popup.postMessage(
{
type: 'channel:init'
},
APP_ORIGIN,
[channel.port2]
);
channel.port1.addEventListener('message', (event) => {
console.log('Повідомлення через окремий канал:', event.data);
});
channel.port1.start();Popup приймає порт:
window.addEventListener('message', (event) => {
if (event.origin !== APP_ORIGIN) {
return;
}
if (event.source !== window.opener) {
return;
}
if (event.data?.type !== 'channel:init') {
return;
}
const [port] = event.ports;
if (!port) {
return;
}
port.addEventListener('message', (portEvent) => {
console.log('Отримано через MessagePort:', portEvent.data);
});
port.start();
port.postMessage({
type: 'channel:ready'
});
});Передавання порту не скасовує первинні перевірки. Повідомлення, яке передає MessagePort, потрібно захистити під час встановлення каналу. Після цього слід також використовувати контрольовані типи повідомлень і перевіряти їхню структуру.
Не обробляйте довільні команди з event.data:
// Небезпечно
window.addEventListener('message', (event) => {
window[event.data.action]();
});Краще використовувати явний список підтримуваних команд:
const handlers = {
'profile:get': () => {
console.log('Запит профілю');
},
'dialog:close': () => {
console.log('Закриття діалогу');
}
};
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) {
return;
}
if (!event.data || typeof event.data.type !== 'string') {
return;
}
const handler = handlers[event.data.type];
if (typeof handler === 'function') {
handler(event.data);
}
});Для складних протоколів корисно додати:
версію повідомлення;
ідентифікатор запиту;
поле ok або error;
обмежений перелік допустимих значень;
перевірку типів вкладених властивостей;
тайм-аут очікування відповіді.
postMessage не шифрує дані окремо від захищеного з’єднання. Безпека передавання також залежить від протоколу та стану сторінок.
Не передавайте без необхідності:
паролі;
довгоживучі access-токени;
секретні ключі;
повні дані платіжних карток;
внутрішні об’єкти стану застосунку.
Якщо popup має отримати результат операції, краще передати короткоживучий ідентифікатор або мінімальний набір даних, а повну інформацію отримати через авторизований запит до сервера.
Popup може:
бути заблокованим;
закритися до відповіді;
втратити зв’язок із батьківським вікном;
змінити origin;
не надіслати handshake через помилку JavaScript.
Тому код не повинен нескінченно чекати на повідомлення:
function waitForPopupReady(popup, timeout = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
window.removeEventListener('message', onMessage);
reject(new Error('Popup не відповів вчасно'));
}, timeout);
function onMessage(event) {
if (event.origin !== window.location.origin) {
return;
}
if (event.source !== popup) {
return;
}
if (event.data?.type !== 'popup:ready') {
return;
}
clearTimeout(timer);
window.removeEventListener('message', onMessage);
resolve();
}
window.addEventListener('message', onMessage);
});
}Для періодичного контролю стану можна перевіряти:
if (popup.closed) {
// Popup закрито
}Однак window.closed не замінює перевірки джерела повідомлень.
* для конфіденційних данихpopup.postMessage({ token }, '*');Замість цього вказуйте точний origin.
event.datawindow.addEventListener('message', (event) => {
if (event.data.type === 'login:success') {
grantAccess();
}
});Будь-яке вікно може надіслати повідомлення з таким type. Потрібні перевірки origin і, за потреби, source.
if (event.origin.endsWith('example.com')) {
// Небезпечно: attackerexample.com також може пройти перевірку
}Порівнюйте origin повністю.
eval(event.data.code);Дані з повідомлення ніколи не повинні ставати довільним JavaScript-кодом.
window.open(`/popup.html?token=${token}`);URL може потрапити в історію, журнали, заголовок Referer або інші системи спостереження.
postMessage перевіряє одержувачаВиклик postMessage не гарантує, що повідомлення обробляє саме ваш код. Захист залежить від правильного targetOrigin і перевірок у приймача.
openerЯкщо сторінка зовнішнього сервісу не повинна взаємодіяти з вашим вікном, відкривайте її з noopener,noreferrer.
Навіть повідомлення від правильного origin може містити помилкові або скомпрометовані дані. Перевіряйте типи, обов’язкові поля та допустимі значення.
Для посилань із target="_blank" використовуйте rel="noopener".
Для зовнішніх вікон, яким не потрібен opener, використовуйте noopener,noreferrer.
postMessage призначений для контрольованого обміну між вікнами, зокрема різних origin.
У postMessage вказуйте точний targetOrigin, а не *.
У кожному обробнику message перевіряйте event.origin.
Для конкретного popup додатково перевіряйте event.source.
Перевіряйте структуру та типи отриманих даних.
Не передавайте секрети через URL або повідомлення без необхідності.
noopener і двосторонній обмін через window.opener — різні моделі, які потрібно свідомо вибрати.
Для складного протоколу використовуйте handshake, тайм-аути, явні типи повідомлень і, за потреби, MessageChannel.