December 7, 2023

Browsing Context, WindowProxy, Window

Каждый Frontend-разработчик знает, что такое объект Window. С самими объектом, вроде бы, все понятно. Но при детальном рассмотрении оказывается, что браузер никогда не отдает этот важнейший глобальный объект напрямую. В этой статье я предлагаю разобраться в спецификации HTML и в том, как именно ведет себя браузер в части глобального контекста.

Начать стоит с того, что в документе может быть несколько глобальных объектов Window (например, iframe на странице). Более того, самих документов тоже может быть несколько (например, открытые табы браузера). Между документами и их объектами Window возможна некоторая связанность посредством frame.contentWindow, self.opener, window.open() и пр. Согласно спецификации, каждый браузерный документ, будь то таб, iframe или что-то еще, является так называемым navigable. В свою очередь, кажды navigable в спецификации HTML называется browsing context. Сам же browsing context имеет ассоциированные объекты WindowProxy и Window. Когда мы переключаем контекст, объект Window меняется на соответствующий, а вот WindowProxy всегда один и тот же.

Дело в том, что WindowProxy является как бы универсальной оберткой-прокси для множества Window. Он прозрачно отдает все доступные свойства Window, однако, сам Window может время от времени меняться. Так же, в отличие от Window, являющимся обычным объектом (ordinary object), WindowProxy является необычным (exotic object). Напомню, обычными считаются все классические объекты в JavaScript, которые мы можем свободно создавать. Необычными считаются те объекты, поведение которых может иметь некую скрытую логику, к таким объектам относят, например Array, String, Arguments и др.

WindowProxy, будучи exotic, имеет слот [[Window]], а так же ряд скрытых методов: GetPrototypeOf, SetPrototypeOf, IsExtensible, PreventExtensions, GetOwnProperty, которые призваны обеспечить работу объекта, как прокси.

Зачем же нужны такие сложности, не проще ли просто отдавать Window как есть? Ответ прост, основная причина - политика безопасности. Мы знаем, что политика same-origin запрещает доступ к коду одного origin из другого. Т.е. нельзя с домена a.com получить доступ к коду b.com. Однако, например, если b.com был размещен внутри a.com через iframe, есть некоторые послабления в политике и доступ к коду все получить можно, что потенциально создает дыры в системе безопасности.

Представим следующий код

Что делает этот код

// вешаем onload колбэк на iframe, который сработает при
// загрузке/перезагрузке фрейма
frame.onload = function onFrameLoad() {
  // после загрузки фрейма сохраним ссылку на `contentWindow`
  cWindow = frame.contentWindow;
  // а так же, создадим глобальную переменную `a` в контексте Window
  cWindow.a = "a";
  
  // Выведем результат на экран.
  //
  // Функция ниже сравнивает ссылку на сохраненный contentWindow
  // c реальным contentWindow:
  //
  // cWindow === frame.contentWindow
  //
  // а так же, выводит значение глобальной переменной `a` из Window
  //
  // const printResult = (id) => {
  //   const isEqual = cWindow === frame.contentWindow;
  //   const value = cWindow.a;
  //
  //   document.getElementById(id).innerHTML = `contentWindow is equal: ${isEqual};\nvalue: ${value}`;
  // }
  printResult("result-1");
  
  // далее повесим новый колбэк на onload этого же iframe
  frame.onload = function () {
    // и еще раз выведем результат после перезгрузки фрейма
    printResult("result-2");
  }
}

Мы видим, что после первой загрузки фрейма ссылка cWindow идентична frame.contentWindow, а глобальная переменная window.a = 'a', как и ожидалось.

После второй загрузки фрейма видим, что cWindow по прежнему идентична frame.contentWindow, но window.a уже не существует в этом контексте. С точки зрения политики безопасности - это правильно, iframe не должен иметь возможности мутировать родительский глобальный объект. Эта и другие проблемы безопасности в дочерних контекстах были предметом головной боли и детального обсуждения Бобби Холлей, Борисом Жбарски, Яном Хиксона, Адамом Варта и Эн ван Кестерен на протяжении многих лет, которое завершилось пул-реквестом "Define security around Window, WindowProxy, and Location properly".

Но как такое оказалось возможным технически? Ведь ссылка на контекст не изменилась? Как в одном и том же контексте переменная может существовать и не существовать одновременно?

Всему виной WindowProxy. Как я уже говорил, работая в объектом Window, мы, на самом деле, всегда обращаемся к WindowProxy, который обеспечивает изоляцию Window внутри browsing context. При этом ссылка на сам WindowProxy остается перманентной. Именно поэтому cWindow === frame.contentWindow всегда возвращает true, обе ссылки - ссылки на WindowProxy. Однако, перезагрузив фрейм мы создали второй контекст Window, который будет существовать внутри WindowProxyManager. Обращаясь к глобальной переменной, WindowProxy определяет, из какого контекста происходит обращение и выставляет нужный Window в слот [[Window]].

Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/ErCH3Qsu6L6