← Браузер/Фреймы и окна#156 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: async и сетьТермин: Event LoopТермин: Core Web Vitals

Фреймы и окна

Представь: ты интегрируешь OAuth авторизацию. Пользователь нажимает «Войти через Google» — открывается попап с Google, пользователь даёт разрешения, попап закрывается, и твоя страница получает токен. Как попап передаёт токен обратно? Через postMessage — единственный безопасный канал связи между окнами с разными источниками.

Что решает этот механизм

Разные окна браузера изолированы друг от друга по правилам same-origin policy — они не могут читать DOM и переменные друг друга (если домены разные). postMessage — официальный, безопасный способ отправить сообщение между окнами, где важна проверка источника.

На основе предыдущих уроков

  • события, addEventListener — message это обычное событие с теми же паттернами обработки
  • кастомные события — postMessage это кастомные события на уровне браузерных окон
  • window.open() — открыть новое окно

    // Синтаксис: window.open(url, target, features)
    const popup = window.open(
      'https://auth.example.com/oauth',
      'oauth-popup',           // имя окна (target)
      'width=600,height=700,left=100,top=100'  // параметры окна
    )
    
    // popup — ссылка на открытое окно (объект window)
    if (!popup) {
      // Браузер заблокировал popup — предупредить пользователя
      alert('Разрешите всплывающие окна для этого сайта')
    }

    Параметры target

    window.open(url, '_blank')   // новая вкладка
    window.open(url, '_self')    // текущее окно (как переход)
    window.open(url, '_parent')  // родительский фрейм
    window.open(url, '_top')     // верхний уровень (выход из фреймов)
    window.open(url, 'myWindow') // именованное окно

    window.opener — доступ к родителю

    Дочернее окно может обратиться к открывшему его окну через window.opener:

    // В дочернем окне (oauth-popup):
    if (window.opener) {
      // Передать результат авторизации обратно
      window.opener.postMessage({ type: 'oauth-success', token: 'abc123' }, 'https://myapp.com')
      window.close()  // закрыть popup
    }

    Доступ к DOM родительского окна через window.opener.document ограничен политикой одного источника (same-origin policy).

    postMessage — безопасный обмен сообщениями

    postMessage — единственный безопасный способ общения между окнами с разными источниками:

    // Отправитель (родительское окно)
    const popup = window.open('https://payment.example.com', 'payment')
    
    // Отправить сообщение с проверкой источника
    popup.postMessage(
      { type: 'init', orderId: '12345', amount: 1990 },
      'https://payment.example.com'  // разрешить только этому источнику
    )
    
    // Получатель — любое окно слушает входящие сообщения
    window.addEventListener('message', (event) => {
      // ВАЖНО: всегда проверять источник!
      if (event.origin !== 'https://payment.example.com') {
        console.warn('Сообщение от недоверенного источника:', event.origin)
        return
      }
    
      console.log('Получено сообщение:', event.data)
      console.log('От кого:', event.origin)
      console.log('source:', event.source)  // ссылка на window-отправитель
    })

    Iframe — встроенный фрейм

    // Получить доступ к iframe
    const frame = document.getElementById('payment-frame')
    
    // Отправить сообщение в iframe
    frame.contentWindow.postMessage({ action: 'pay' }, 'https://payment.example.com')
    
    // Слушать ответы от iframe
    window.addEventListener('message', (event) => {
      if (event.source === frame.contentWindow) {
        console.log('Ответ от iframe:', event.data)
      }
    })

    Безопасность: что нельзя делать

    // ПЛОХО: принимать сообщения от любого источника
    window.addEventListener('message', (event) => {
      executeCode(event.data)  // ОПАСНО — не проверяем event.origin!
    })
    
    // ПЛОХО: отправлять в любой источник
    popup.postMessage(sensitiveData, '*')  // '*' = кому угодно — опасно
    
    // ХОРОШО: всегда проверять и указывать origin
    window.addEventListener('message', (event) => {
      if (event.origin !== TRUSTED_ORIGIN) return  // отклонить чужих
      processMessage(event.data)
    })
    popup.postMessage(data, TRUSTED_ORIGIN)  // конкретный origin

    Реальные сценарии

  • OAuth popup: открыть окно авторизации, получить токен через postMessage
  • Виджет оплаты: встроенный iframe, изолированный от основной страницы
  • Предпросмотр: iframe с редактируемым контентом в отдельном sandbox
  • Чат-виджет: небольшое окно поверх основного контента
  • Типичные ошибки

    1. Принимать postMessage без проверки event.origin — уязвимость XSS

    // ПЛОХО — любой сайт может отправить сообщение
    window.addEventListener('message', (event) => {
      processPayment(event.data)  // ОПАСНО — не проверяем кто прислал!
    })
    
    // ХОРОШО — всегда проверять источник
    const TRUSTED_ORIGIN = 'https://payment.example.com'
    window.addEventListener('message', (event) => {
      if (event.origin !== TRUSTED_ORIGIN) {
        console.warn('Сообщение от недоверенного источника:', event.origin)
        return
      }
      processPayment(event.data)
    })

    2. Использовать '*' как targetOrigin при postMessage с чувствительными данными

    // ПЛОХО — сообщение с токеном отправится любому окну
    popup.postMessage({ token: 'secret-token-123' }, '*')  // опасно!
    
    // ХОРОШО — указывай конкретный origin
    popup.postMessage({ token: 'secret-token-123' }, 'https://myapp.com')
    // Если origin не совпадает — сообщение не будет доставлено

    3. Обращаться к popup.document без проверки same-origin

    // ПЛОХО — выбросит SecurityError если popup на другом домене
    const popup = window.open('https://other-domain.com')
    const title = popup.document.title  // SecurityError: cross-origin access!
    
    // ХОРОШО — используй postMessage для коммуникации
    popup.postMessage({ type: 'get-title' }, 'https://other-domain.com')
    window.addEventListener('message', event => {
      if (event.data.type === 'title-response') console.log(event.data.title)
    })

    В реальных проектах

  • OAuth (GitHub, Google, VK): авторизация через попап — классический сценарий с window.open + postMessage
  • Виджеты оплаты (Stripe, ЮKassa): форма ввода карты в iframe изолирована от основной страницы для безопасности PCI DSS
  • Micro-frontend: независимые команды разворачивают части приложения в iframe, общаются через postMessage
  • Примеры

    Симуляция postMessage между окнами: OAuth popup, обмен сообщениями с проверкой источника

    // Симуляция межоконного взаимодействия через mock postMessage систему
    // В браузере окна — реальные объекты Window
    
    // --- Mock Window и MessageBus ---
    function createMockWindow(origin, name = 'unnamed') {
      const listeners = []
      let opener = null
    
      const win = {
        name,
        origin,
        opener: null,
        closed: false,
    
        // Симуляция window.postMessage
        postMessage(data, targetOrigin) {
          if (targetOrigin !== '*' && targetOrigin !== this.origin) {
            console.log(`[${name}] postMessage отклонён: origin "${this.origin}" не совпадает с targetOrigin "${targetOrigin}"`)
            return
          }
          const event = { data, origin: this.origin, source: this }
          console.log(`[${name}] postMessage → данные: ${JSON.stringify(data)}`)
          // Рассылаем сообщение всем слушателям (симуляция)
          this._deliverMessage(event)
        },
    
        addEventListener(type, handler) {
          if (type === 'message') listeners.push(handler)
        },
    
        // Внутренний метод: принять входящее сообщение
        _receiveMessage(data, fromOrigin, fromWindow) {
          const event = { data, origin: fromOrigin, source: fromWindow }
          listeners.forEach(h => h(event))
        },
    
        _deliverMessage(event) {
          // В реальности браузер доставляет сообщение в целевое окно
          // Здесь это делает MessageBus
        },
    
        close() {
          this.closed = true
          console.log(`[${name}] окно закрыто`)
        },
      }
    
      return win
    }
    
    // MessageBus связывает окна
    function createMessageBus() {
      const windows = new Map()
    
      return {
        register(win) {
          windows.set(win.name, win)
        },
    
        send(fromWin, toWin, data, targetOrigin) {
          if (targetOrigin !== '*' && targetOrigin !== toWin.origin) {
            console.log(`[Bus] Блокировка: targetOrigin "${targetOrigin}" не совпадает с "${toWin.origin}"`)
            return
          }
          console.log(`[Bus] ${fromWin.name} → ${toWin.name}: ${JSON.stringify(data)}`)
          toWin._receiveMessage(data, fromWin.origin, fromWin)
        },
      }
    }
    
    // --- Демо 1: OAuth popup ---
    console.log('=== OAuth Popup симуляция ===')
    
    const mainApp = createMockWindow('https://myapp.com', 'main')
    const oauthPopup = createMockWindow('https://auth.provider.com', 'oauth-popup')
    oauthPopup.opener = mainApp
    
    const bus = createMessageBus()
    bus.register(mainApp)
    bus.register(oauthPopup)
    
    // Главное окно слушает результат OAuth
    mainApp.addEventListener('message', (event) => {
      if (event.origin !== 'https://auth.provider.com') {
        console.log('[main] Блокируем сообщение от', event.origin)
        return
      }
      console.log('[main] Получен OAuth результат:', event.data)
      if (event.data.type === 'oauth-success') {
        console.log(`[main] Авторизован! Токен: ${event.data.token}`)
        console.log('[main] Сохраняем токен и обновляем UI...')
      }
    })
    
    // OAuth popup завершает авторизацию
    console.log('[oauth-popup] Пользователь нажал "Разрешить"')
    bus.send(oauthPopup, mainApp, { type: 'oauth-success', token: 'eyJhbGciOiJSUzI1NiJ9...' }, 'https://myapp.com')
    oauthPopup.close()
    
    // --- Демо 2: Виджет оплаты ---
    console.log('\n=== Виджет оплаты (iframe) ===')
    
    const shopPage = createMockWindow('https://shop.example.com', 'shop')
    const payWidget = createMockWindow('https://pay.gateway.com', 'payment-widget')
    bus.register(shopPage)
    bus.register(payWidget)
    
    // Виджет слушает команды от магазина
    payWidget.addEventListener('message', (event) => {
      if (event.origin !== 'https://shop.example.com') return
      console.log('[payment-widget] Получена команда:', event.data)
    
      if (event.data.action === 'initialize') {
        console.log(`[payment-widget] Инициализируем платёж на сумму ${event.data.amount} руб.`)
        // Симулируем подтверждение
        setTimeout(() => {
          bus.send(payWidget, shopPage, { type: 'payment-ready' }, 'https://shop.example.com')
        }, 100)
      }
    
      if (event.data.action === 'charge') {
        console.log(`[payment-widget] Обрабатываем оплату...`)
        setTimeout(() => {
          bus.send(payWidget, shopPage, { type: 'payment-success', transactionId: 'TXN-98765' }, 'https://shop.example.com')
        }, 150)
      }
    })
    
    // Магазин слушает ответы виджета
    shopPage.addEventListener('message', (event) => {
      if (event.origin !== 'https://pay.gateway.com') return
      console.log('[shop] Сообщение от виджета:', event.data)
    
      if (event.data.type === 'payment-ready') {
        console.log('[shop] Виджет готов — отправляем команду оплаты')
        bus.send(shopPage, payWidget, { action: 'charge', orderId: 'ORD-42' }, 'https://pay.gateway.com')
      }
    
      if (event.data.type === 'payment-success') {
        console.log(`[shop] Оплата прошла! ID транзакции: ${event.data.transactionId}`)
      }
    })
    
    // Запускаем сценарий
    bus.send(shopPage, payWidget, { action: 'initialize', amount: 4990 }, 'https://pay.gateway.com')
    
    // --- Демо 3: Атака через некорректный origin ---
    console.log('\n=== Проверка безопасности (блокировка чужих сообщений) ===')
    
    const maliciousWindow = createMockWindow('https://evil.hacker.com', 'attacker')
    bus.register(maliciousWindow)
    
    // Попытка взломщика отправить сообщение
    bus.send(maliciousWindow, mainApp, { type: 'oauth-success', token: 'stolen!' }, 'https://myapp.com')
    // mainApp проверит event.origin и отклонит — т.к. origin 'https://evil.hacker.com'

    Фреймы и окна

    Представь: ты интегрируешь OAuth авторизацию. Пользователь нажимает «Войти через Google» — открывается попап с Google, пользователь даёт разрешения, попап закрывается, и твоя страница получает токен. Как попап передаёт токен обратно? Через postMessage — единственный безопасный канал связи между окнами с разными источниками.

    Что решает этот механизм

    Разные окна браузера изолированы друг от друга по правилам same-origin policy — они не могут читать DOM и переменные друг друга (если домены разные). postMessage — официальный, безопасный способ отправить сообщение между окнами, где важна проверка источника.

    На основе предыдущих уроков

  • события, addEventListener — message это обычное событие с теми же паттернами обработки
  • кастомные события — postMessage это кастомные события на уровне браузерных окон
  • window.open() — открыть новое окно

    // Синтаксис: window.open(url, target, features)
    const popup = window.open(
      'https://auth.example.com/oauth',
      'oauth-popup',           // имя окна (target)
      'width=600,height=700,left=100,top=100'  // параметры окна
    )
    
    // popup — ссылка на открытое окно (объект window)
    if (!popup) {
      // Браузер заблокировал popup — предупредить пользователя
      alert('Разрешите всплывающие окна для этого сайта')
    }

    Параметры target

    window.open(url, '_blank')   // новая вкладка
    window.open(url, '_self')    // текущее окно (как переход)
    window.open(url, '_parent')  // родительский фрейм
    window.open(url, '_top')     // верхний уровень (выход из фреймов)
    window.open(url, 'myWindow') // именованное окно

    window.opener — доступ к родителю

    Дочернее окно может обратиться к открывшему его окну через window.opener:

    // В дочернем окне (oauth-popup):
    if (window.opener) {
      // Передать результат авторизации обратно
      window.opener.postMessage({ type: 'oauth-success', token: 'abc123' }, 'https://myapp.com')
      window.close()  // закрыть popup
    }

    Доступ к DOM родительского окна через window.opener.document ограничен политикой одного источника (same-origin policy).

    postMessage — безопасный обмен сообщениями

    postMessage — единственный безопасный способ общения между окнами с разными источниками:

    // Отправитель (родительское окно)
    const popup = window.open('https://payment.example.com', 'payment')
    
    // Отправить сообщение с проверкой источника
    popup.postMessage(
      { type: 'init', orderId: '12345', amount: 1990 },
      'https://payment.example.com'  // разрешить только этому источнику
    )
    
    // Получатель — любое окно слушает входящие сообщения
    window.addEventListener('message', (event) => {
      // ВАЖНО: всегда проверять источник!
      if (event.origin !== 'https://payment.example.com') {
        console.warn('Сообщение от недоверенного источника:', event.origin)
        return
      }
    
      console.log('Получено сообщение:', event.data)
      console.log('От кого:', event.origin)
      console.log('source:', event.source)  // ссылка на window-отправитель
    })

    Iframe — встроенный фрейм

    // Получить доступ к iframe
    const frame = document.getElementById('payment-frame')
    
    // Отправить сообщение в iframe
    frame.contentWindow.postMessage({ action: 'pay' }, 'https://payment.example.com')
    
    // Слушать ответы от iframe
    window.addEventListener('message', (event) => {
      if (event.source === frame.contentWindow) {
        console.log('Ответ от iframe:', event.data)
      }
    })

    Безопасность: что нельзя делать

    // ПЛОХО: принимать сообщения от любого источника
    window.addEventListener('message', (event) => {
      executeCode(event.data)  // ОПАСНО — не проверяем event.origin!
    })
    
    // ПЛОХО: отправлять в любой источник
    popup.postMessage(sensitiveData, '*')  // '*' = кому угодно — опасно
    
    // ХОРОШО: всегда проверять и указывать origin
    window.addEventListener('message', (event) => {
      if (event.origin !== TRUSTED_ORIGIN) return  // отклонить чужих
      processMessage(event.data)
    })
    popup.postMessage(data, TRUSTED_ORIGIN)  // конкретный origin

    Реальные сценарии

  • OAuth popup: открыть окно авторизации, получить токен через postMessage
  • Виджет оплаты: встроенный iframe, изолированный от основной страницы
  • Предпросмотр: iframe с редактируемым контентом в отдельном sandbox
  • Чат-виджет: небольшое окно поверх основного контента
  • Типичные ошибки

    1. Принимать postMessage без проверки event.origin — уязвимость XSS

    // ПЛОХО — любой сайт может отправить сообщение
    window.addEventListener('message', (event) => {
      processPayment(event.data)  // ОПАСНО — не проверяем кто прислал!
    })
    
    // ХОРОШО — всегда проверять источник
    const TRUSTED_ORIGIN = 'https://payment.example.com'
    window.addEventListener('message', (event) => {
      if (event.origin !== TRUSTED_ORIGIN) {
        console.warn('Сообщение от недоверенного источника:', event.origin)
        return
      }
      processPayment(event.data)
    })

    2. Использовать '*' как targetOrigin при postMessage с чувствительными данными

    // ПЛОХО — сообщение с токеном отправится любому окну
    popup.postMessage({ token: 'secret-token-123' }, '*')  // опасно!
    
    // ХОРОШО — указывай конкретный origin
    popup.postMessage({ token: 'secret-token-123' }, 'https://myapp.com')
    // Если origin не совпадает — сообщение не будет доставлено

    3. Обращаться к popup.document без проверки same-origin

    // ПЛОХО — выбросит SecurityError если popup на другом домене
    const popup = window.open('https://other-domain.com')
    const title = popup.document.title  // SecurityError: cross-origin access!
    
    // ХОРОШО — используй postMessage для коммуникации
    popup.postMessage({ type: 'get-title' }, 'https://other-domain.com')
    window.addEventListener('message', event => {
      if (event.data.type === 'title-response') console.log(event.data.title)
    })

    В реальных проектах

  • OAuth (GitHub, Google, VK): авторизация через попап — классический сценарий с window.open + postMessage
  • Виджеты оплаты (Stripe, ЮKassa): форма ввода карты в iframe изолирована от основной страницы для безопасности PCI DSS
  • Micro-frontend: независимые команды разворачивают части приложения в iframe, общаются через postMessage
  • Примеры

    Симуляция postMessage между окнами: OAuth popup, обмен сообщениями с проверкой источника

    // Симуляция межоконного взаимодействия через mock postMessage систему
    // В браузере окна — реальные объекты Window
    
    // --- Mock Window и MessageBus ---
    function createMockWindow(origin, name = 'unnamed') {
      const listeners = []
      let opener = null
    
      const win = {
        name,
        origin,
        opener: null,
        closed: false,
    
        // Симуляция window.postMessage
        postMessage(data, targetOrigin) {
          if (targetOrigin !== '*' && targetOrigin !== this.origin) {
            console.log(`[${name}] postMessage отклонён: origin "${this.origin}" не совпадает с targetOrigin "${targetOrigin}"`)
            return
          }
          const event = { data, origin: this.origin, source: this }
          console.log(`[${name}] postMessage → данные: ${JSON.stringify(data)}`)
          // Рассылаем сообщение всем слушателям (симуляция)
          this._deliverMessage(event)
        },
    
        addEventListener(type, handler) {
          if (type === 'message') listeners.push(handler)
        },
    
        // Внутренний метод: принять входящее сообщение
        _receiveMessage(data, fromOrigin, fromWindow) {
          const event = { data, origin: fromOrigin, source: fromWindow }
          listeners.forEach(h => h(event))
        },
    
        _deliverMessage(event) {
          // В реальности браузер доставляет сообщение в целевое окно
          // Здесь это делает MessageBus
        },
    
        close() {
          this.closed = true
          console.log(`[${name}] окно закрыто`)
        },
      }
    
      return win
    }
    
    // MessageBus связывает окна
    function createMessageBus() {
      const windows = new Map()
    
      return {
        register(win) {
          windows.set(win.name, win)
        },
    
        send(fromWin, toWin, data, targetOrigin) {
          if (targetOrigin !== '*' && targetOrigin !== toWin.origin) {
            console.log(`[Bus] Блокировка: targetOrigin "${targetOrigin}" не совпадает с "${toWin.origin}"`)
            return
          }
          console.log(`[Bus] ${fromWin.name} → ${toWin.name}: ${JSON.stringify(data)}`)
          toWin._receiveMessage(data, fromWin.origin, fromWin)
        },
      }
    }
    
    // --- Демо 1: OAuth popup ---
    console.log('=== OAuth Popup симуляция ===')
    
    const mainApp = createMockWindow('https://myapp.com', 'main')
    const oauthPopup = createMockWindow('https://auth.provider.com', 'oauth-popup')
    oauthPopup.opener = mainApp
    
    const bus = createMessageBus()
    bus.register(mainApp)
    bus.register(oauthPopup)
    
    // Главное окно слушает результат OAuth
    mainApp.addEventListener('message', (event) => {
      if (event.origin !== 'https://auth.provider.com') {
        console.log('[main] Блокируем сообщение от', event.origin)
        return
      }
      console.log('[main] Получен OAuth результат:', event.data)
      if (event.data.type === 'oauth-success') {
        console.log(`[main] Авторизован! Токен: ${event.data.token}`)
        console.log('[main] Сохраняем токен и обновляем UI...')
      }
    })
    
    // OAuth popup завершает авторизацию
    console.log('[oauth-popup] Пользователь нажал "Разрешить"')
    bus.send(oauthPopup, mainApp, { type: 'oauth-success', token: 'eyJhbGciOiJSUzI1NiJ9...' }, 'https://myapp.com')
    oauthPopup.close()
    
    // --- Демо 2: Виджет оплаты ---
    console.log('\n=== Виджет оплаты (iframe) ===')
    
    const shopPage = createMockWindow('https://shop.example.com', 'shop')
    const payWidget = createMockWindow('https://pay.gateway.com', 'payment-widget')
    bus.register(shopPage)
    bus.register(payWidget)
    
    // Виджет слушает команды от магазина
    payWidget.addEventListener('message', (event) => {
      if (event.origin !== 'https://shop.example.com') return
      console.log('[payment-widget] Получена команда:', event.data)
    
      if (event.data.action === 'initialize') {
        console.log(`[payment-widget] Инициализируем платёж на сумму ${event.data.amount} руб.`)
        // Симулируем подтверждение
        setTimeout(() => {
          bus.send(payWidget, shopPage, { type: 'payment-ready' }, 'https://shop.example.com')
        }, 100)
      }
    
      if (event.data.action === 'charge') {
        console.log(`[payment-widget] Обрабатываем оплату...`)
        setTimeout(() => {
          bus.send(payWidget, shopPage, { type: 'payment-success', transactionId: 'TXN-98765' }, 'https://shop.example.com')
        }, 150)
      }
    })
    
    // Магазин слушает ответы виджета
    shopPage.addEventListener('message', (event) => {
      if (event.origin !== 'https://pay.gateway.com') return
      console.log('[shop] Сообщение от виджета:', event.data)
    
      if (event.data.type === 'payment-ready') {
        console.log('[shop] Виджет готов — отправляем команду оплаты')
        bus.send(shopPage, payWidget, { action: 'charge', orderId: 'ORD-42' }, 'https://pay.gateway.com')
      }
    
      if (event.data.type === 'payment-success') {
        console.log(`[shop] Оплата прошла! ID транзакции: ${event.data.transactionId}`)
      }
    })
    
    // Запускаем сценарий
    bus.send(shopPage, payWidget, { action: 'initialize', amount: 4990 }, 'https://pay.gateway.com')
    
    // --- Демо 3: Атака через некорректный origin ---
    console.log('\n=== Проверка безопасности (блокировка чужих сообщений) ===')
    
    const maliciousWindow = createMockWindow('https://evil.hacker.com', 'attacker')
    bus.register(maliciousWindow)
    
    // Попытка взломщика отправить сообщение
    bus.send(maliciousWindow, mainApp, { type: 'oauth-success', token: 'stolen!' }, 'https://myapp.com')
    // mainApp проверит event.origin и отклонит — т.к. origin 'https://evil.hacker.com'

    Задание

    Реализуй класс WindowBridge — систему типобезопасного общения между окнами. Метод connect(localWin, remoteWin, trustedOrigin) создаёт соединение. Метод send(type, data) отправляет сообщение в удалённое окно. Метод on(type, handler) подписывается на сообщения определённого типа от доверенного источника. Метод destroy() удаляет все обработчики.

    Подсказка

    connect: _messageHandler проверяет event.origin !== this._trustedOrigin, затем берёт тип из event.data.type и вызывает handler из this._handlers. send: вызывает remote._receiveMessage с { type, data }. on: this._handlers.set(type, handler). destroy: removeEventListener и _handlers.clear().

    Загружаем среду выполнения...
    Загружаем AI-помощника...