Пошаговое руководство по ограничению разрешений iframe с помощью свойства песочницы

Что такое тестовые среды iFrame и безопасность iFrame

Встраивание стороннего JavaScript в веб-приложения - старинная сказка. Будь то размещение виджета на вашей веб-странице или включение пользовательского контента из клиента в ваше облачное приложение, многие разработчики сталкивались с этим в своей карьере. Все мы знаем об элементе iframe в HTML, но сколько мы действительно знаем о том, как он работает? Какие проблемы безопасности связаны с запуском кода внутри iframe и, кроме того, как атрибут песочницы HTML5 во фрейме может решить эти проблемы?

Цель этого руководства - рассмотреть различные риски безопасности, связанные с запуском стороннего JavaScript на вашей странице, и объяснить, как изолированные фреймы iframe могут решить эти проблемы, ограничивая разрешения, с которыми им разрешен запуск.

В этом посте мы продемонстрируем настройку демонстрационного приложения с нуля, которое будет имитировать запуск JavaScript из другого источника. В итоге мы должны получить изолированную среду, в которой мы можем выполнять любой произвольный JavaScript и при этом спокойно спать по ночам, зная, что наше основное приложение будет в безопасности от повреждений.

Учитывая все это, пошаговое руководство будет состоять из следующих частей:

  1. Настройка двух узловых серверов для имитации двух разных источников
  2. Встраивание содержимого нашей клиентской страницы в iframe на главной странице и изучение того, что такое клиентский iframe, а что ему не разрешено.
  3. Применение атрибута песочницы к iframe и изучение различных параметров песочницы.

Давайте начнем!

Шаг 1: Настройка серверов для нашего демонстрационного приложения

Чтобы смоделировать выполнение кода из другого источника, мы собираемся настроить два сервера узлов - один, который мы назовем host, и второй, который мы назовем client. Мы можем сделать это, используя библиотеку узла http для прослушивания и обслуживания с двух разных портов.

// server.js
const http = require('http');
const hostname = 'localhost';
const host_port = 8000;
const client_port = 8001;
const host_server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('This is the Host Server\n');
}).listen(host_port, hostname, () => {
  console.log(`Server running at http://${hostname}:${host_port}/`);
});;
const client_server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('This is the Client Server\n');
}).listen(client_port, hostname, () => {
  console.log(`Client running at http://${hostname}:${client_port}/`);
});;

Сохраните этот JS-файл под любым именем - я назвал его server.js. Затем, чтобы запустить наш сервер, мы можем просто запустить:

node server.js

Это должно запустить два разных http-сервера, один на порту 8000, а второй на порту 8001. Чтобы проверить, что он работает, вы можете индивидуально посетить свой локальный хост на портах 8000 и 80001, которые должны выглядеть следующим образом:

Несмотря на то, что оба они работают на localhost, политика одного и того же происхождения, которую реализуют браузеры, работает с кортежем протокол-хост-порт, который будет истинным только в том случае, если протокол, имя хоста и номер порта совпадают. В этом случае протокол и хост одинаковы, но поскольку порты разные, они будут считаться разными по происхождению.

Конечно, простой жестко запрограммированный ответ нас далеко не уедет. Для этой демонстрации нам потребуется поддерживать как HTML, так и JS. Для этого я создал функцию для обслуживания ресурсов из заданной папки.

function serveAsset(rootPath, url, res) {
  // default root route to index.html in the folder
  if (url === '/') url = 'index.html';
  const filePath = path.join(__dirname, rootPath, url)
  const readStream = fileSystem.createReadStream(filePath)
    .on('error', function() {
      res.statusCode = 404;
      res.end();
    });
  if (/^.*\.js$/.test(url)) {
    res.setHeader('Content-Type', 'text/javascript');
  } else {
    res.setHeader('Content-Type', 'text/html');
  }
  readStream.pipe(res);
}

Эта функция принимает путь к корневой папке, URL-адрес и объект ответа. Если файл не найден, он вернет 404 и, если он будет найден, установит для заголовка значение text/javascript или text/html в зависимости от суффикса файла. Чтобы это работало, нам нужно включить еще две зависимости в верхнюю часть файла:

const fileSystem = require('fs');
const path = require('path');

Интересный факт - Поскольку мы просто используем встроенные библиотеки узлов, нам не нужно ничего устанавливать через npm! После того, как вы создадите экземпляры fileSystem, path и нашу функцию ресурсов, продолжайте и обновите свои серверы, чтобы вызвать serveAsset.

const host_server = http.createServer((req, res) => {
  serveAsset('host', req.url, res)
}).listen(host_port, hostname, () => {
  console.log(`Server running at http://${hostname}:${host_port}/`);
});;
const client_server = http.createServer((req, res) => {
  serveAsset('client', req.url, res)
}).listen(client_port, hostname, () => {
  console.log(`Client running at http://${hostname}:${client_port}/`);
});

Теперь они выглядят очень похоже. Единственная разница в том, что host_server будет искать свои активы в папке хоста, а client_server будет искать в папке клиента. Если бы мы перезапустили наш сервер сейчас, мы бы увидели следующее сообщение об ошибке:

Это потому, что наша serveAsset функция ищет ресурс для обслуживания в папке host или client, а мы еще не создали их! Давайте создадим их обоих, каждый с index.html и файлом JS.

» mkdir host; mkdir client; touch host/index.html; touch host/host.js; touch client/index.html; touch client/client.js

Наша файловая структура должна выглядеть так:

[-]host
  index.html
  host.js
[-]client
  index.html
  client.js
server.js

Теперь, если мы запустим наш сервер и посетим наш локальный хост, мы больше не получим 404, что означает, что наш сервер нашел файл! - но в нем пока нет содержания. Чтобы добавить немного содержания, давайте начнем с чего-нибудь очень простого. Для хоста у нас просто есть HTML как:

<!-- index.html -->
<html>
  <head>
  </head>
  <body>
    <h1>Host Page</h1>
    <p>Host message container</p>
    <script type="text/javascript" src="host.js"></script>
  </body>
</html>

И JavaScript как:

alert('hello from the host')

Содержимое клиента точно такое же, только слово host заменено на client.

Если мы перезапустим наш сервер сейчас, мы сможем перейти как на http: // localhost: 8000 /, так и http: // localhost: 8001 / и увидеть наш контент в действии! Каждая страница должна отправить предупреждение из файла JS, а затем отобразить на странице наш html-контент.

Шаг 2: встраивание клиента в хост без песочницы и исследования его разрешений

Теперь, когда наши два сервера работают, мы готовы приступить к тестированию некоторых сценариев iframe. Прежде чем мы это сделаем, давайте добавим второй файл на наш хост, чтобы мы могли сравнить разрешения iframe из того же источника и iframe из другого источника.

touch host/hosted-client.html; touch host/hosted-client.js

Заполните содержимое этих файлов тем же содержимым, которое мы использовали для других наших пар html / JS. Мы будем называть его «размещенным клиентом», что означает клиент iframe, исходящий из того же источника, что и наш хост.

Как только мы это сделаем, вернувшись в наш host/index.html, мы сможем использовать iframe как для нашего клиента с одинаковым происхождением, так и для нашего клиента с другим происхождением.

<iframe width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

Обратите внимание, что мы используем localhost для размещенного клиента и 127.0.0.1 для другого. Это станет важным в разделе файлов cookie ниже. При обновлении главной страницы вы должны увидеть два окна iframe, каждый с содержимым наших отдельных файлов HTML.

Если у вас есть предупреждения в ваших JS-файлах, как у меня, вы должны были видеть, что каждый отдельный файл запускает предупреждение на верхнем уровне браузера. Должен ли он это сделать? Этот вопрос подводит нас к нашей первой проблемной области - всплывающим окнам и диалоговым окнам.

Всплывающие окна и диалоговые окна

В JavaScript есть три разные функции, запускающие всплывающее окно - alert, prompt и confirm. Каждый из них открывает диалоговое окно в верхней части контекста просмотра, независимо от того, происходит оно из окна верхнего уровня или нет. Самое страшное в этих диалогах, как можно найти в документации по предупреждению:

Диалоговые окна являются модальными окнами - они не позволяют пользователю получить доступ к остальной части интерфейса программы, пока диалоговое окно не будет закрыто. По этой причине вам не следует злоупотреблять какой-либо функцией, которая создает диалоговое окно (или модальное окно).

Я уверен, что большинство из вас в какой-то момент своей жизни было насильственно перенаправлено на сайт, рассылающий спам, против вашей воли, который затем засыпал вас такими типами диалогов. Даже когда вы пытаетесь закрыть его, он просто открывает еще один. Это надоедливое поведение полностью лишает вас возможности пользоваться сайтом, и, к сожалению, его невероятно просто воспроизвести. Попробуйте добавить это в свой client.js файл:

(function unescapablePrompt() {
  if (window.confirm("Do you want to win $1000?!?!")) {
    /* Open some spammy webpage or redirect */
    unescapablePrompt()
  } else {
    unescapablePrompt()
  }
}());

Теперь, когда вы посещаете свой хост-сайт, вы получаете следующее:

Эти запросы делают невозможным взаимодействие со страницей, и вам нужно закрыть всю вкладку или остановить выполнение JavaScript, чтобы избавиться от нее. Хуже всего то, что независимо от того, поступает ли ваш встроенный контент с другого хоста или из того же источника, это поведение можно использовать.

Вы можете подумать, что можете просто сделать что-то вроде этого, чтобы избавиться от него:

alert = prompt = confirm = function () { } // does not work!

Проблема заключается в том, что каждый iframe работает в своем собственном вложенном контексте просмотра, поэтому переопределение функций на уровне хоста не повлияет на функции на уровне фрейма.

К счастью, песочница может прийти нам на помощь, что мы увидим позже в этом посте. А пока давайте перейдем к вопросу, может ли iframe перемещаться по странице от текущей.

Шаг 3. Навигация по окнам верхнего уровня и открытие новых вкладок

Давайте теперь посмотрим, как и могут ли фреймы изменять URL-адрес окна верхнего уровня и могут ли они открывать новое окно.

Здесь мы хотим протестировать два разных метода.

  1. window.open для открытия новых окон и вкладок, а также
  2. window.location для перехода по странице с текущего URL.

Полезное примечание: окна iframe могут ссылаться на свое окно верхнего уровня с помощью window.top. Точно так же он может ссылаться на свое родительское окно с помощью window.parent. В нашем случае они делают то же самое.

Удалим весь код из client.js и заменим его на:

window.top.location = 'http://localhost:8001'

Это попытается перенаправить окно верхнего уровня на клиентский хост. Если мы запустим это, мы получим следующую ошибку:

Замечательно! Он блокирует автоматическое перенаправление. Но подождите - что это за последняя часть? «… При этом не было получено пользовательского жеста». Что это обозначает? Означает ли это, что инициированный пользователем жест может перемещаться по окну? Давай попробуем.

// client.js
function clickNav () {
  window.top.location = 'http://localhost:8001'
}
// client/index.html
<a href="" onclick="clickNav()">Navigate me</a>

Если мы добавим этот код в наши JS и HTML соответственно, он добавит ссылку на клиентскую страницу. Когда мы щелкаем по нему, страница перемещается!

Как оказалось, действие, инициированное пользователем, может перемещаться по окну верхнего уровня. Это основная идея кликджекинга. Типичная атака кликджекинга помещает прозрачные блоки кликов на страницу, а затем захватывает клик, чтобы перенаправить страницу на другой URL. window.open работает так же.

// client.js
function clickNav () {
  window.open('http://localhost:8001')
}
window.open('http://localhost:8001')

Если у нас есть это, браузер заблокирует всплывающий запрос вызова внешней функции, однако, когда он у нас есть в обработчике кликов, он откроет окно независимо от того, заблокировали ли мы всплывающие окна или нет.

Другая часть вышеупомянутого сообщения об ошибке, в которой говорится, что iframe «не имеет того же источника, что и окно, на которое он нацелен», означает, что наш iframe имеет то же происхождение, что и хост, и у него есть полный доступ для перенаправления страницы, действия щелчка или нет. .

// hosted-client.js
window.top.location = 'http://localhost:8001'

Если вы запустите это, браузер разрешит перенаправление, поскольку оно того же источника. Это не относится к window.open. Даже если они из того же источника, браузер заблокирует window.open, если вы явно не укажете браузеру разрешить всплывающее окно.

Файлы cookie и запросы браузера

Последнее, что мы рассмотрим, - это файлы cookie браузера. Прежде чем начать, убедитесь, что iframe вашего размещенного клиента указывает на localhost, а ваш client.js iframe - на 127.0.0.1.

<iframe width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

Нам нужно это сделать, потому что файлы cookie заботятся о домене и игнорируют порт. После того, как вы проверите это, давайте установим cookie на хосте.

// host.js
document.cookie = "session_id=A38XJISDASDW120"

Это идентификатор сеанса, который часто включается в запросы. Посмотрим, могут ли наши окна iframe получать доступ к файлам cookie.

// in client.js
console.log(document.cookie) // ""
// in hosted-client.js
console.log(document.cookie) // session_id=A38XJISDASDW120

Как видите, клиент не может получить доступ к файлам cookie, так как это другой источник, а клиент хостинга может. Попробуем сделать запрос с помощью fetch API.

// hosted-client.js
var myRequest = new Request('http://localhost:8000');
fetch(myRequest, {
  method: 'GET',
  credentials: "include"
}).then(function(response) {
  console.log(response)
});

Когда мы это сделаем, мы получим 200 ответов.

Response {type: "basic", url: "http://localhost:8000/", redirected: false, status: 200, ok: true, …}

Это означает, что сервер принял запрос и дал нам ответ, но отправил ли он файлы cookie? Мы можем проверить это на стороне сервера. Давайте добавим консольный журнал в обработчик запросов нашего хост-сервера.

const host_server = http.createServer((req, res) => {
  console.log(req.headers)
  serveAsset('host', req.url, res)
}).listen(host_port, hostname, () => {
  console.log(`Server running at http://${hostname}:${host_port}/`);
})

Это выведет заголовки, когда мы сделаем запрос на наш хост-сервер. Обязательно перезапустите сервер после добавления этой строки, а затем перезагрузите страницу и найдите запрос, исходящий от hosted-client. Выглядит это так:

{ 
  host: 'localhost:8000',
  connection: 'keep-alive',
  'user-agent': 'Mozilla/5.0 (Macintosh......',
  accept: '*/*',
  referer: 'http://localhost:8000/hosted-client.html',
  'accept-encoding': 'gzip, deflate, br',
  'accept-language': 'en-US,en;q=0.9',
  cookie: 'session_id=A38XJISDASDW120'
}

Как видите, он отправляет файлы cookie. Если бы сервер использовал только идентификатор сеанса для аутентификации запроса, он бы подумал, что это законный запрос.

Шаг 4. Применение атрибута песочницы к iframe

На данный момент мы выявили четыре области, вызывающие беспокойство при работе с фреймами.

  1. Они могут использовать всплывающие диалоговые окна, чтобы предотвратить взаимодействие с веб-сайтом.
  2. Перемещение по окну верхнего уровня с помощью кликджекинга даже в iframe разного происхождения
  3. Навигация по окну верхнего уровня при одинаковом источнике даже без взаимодействия с пользователем
  4. Окна iframe с одинаковым происхождением могут отправлять запросы с помощью файлов cookie.

Теперь мы собираемся начать использовать атрибут песочницы для окон iframe, представленный в HTML5. При добавлении в iframe изолированный iframe ограничивает практически все скрипты и поведение браузера любого типа. Только когда мы добавим разрешения в список, разделенный пробелами, мы активируем именно те разрешения, которые хотим установить. Чтобы увидеть его начальное состояние, добавьте атрибут в виде пустой строки в оба наших окна iframe.

<iframe sandbox="" width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe sandbox="" width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

Когда мы помещаем iframe в песочницу, он блокирует выполнение всех скриптов.

Чтобы это заработало, нужно поочередно разрешать различные разрешения. Полный список строковых значений можно найти в документации iframe в разделе песочница. Начнем с разрешающих скриптов.

Разрешение скриптов

Для начала давайте очистим наши client.js и hosted-client.js и начнем с простого журнала консоли.

console.log("I executed!")

Без определения каких-либо разрешений наша песочница не позволит запускать журнал консоли. Мы можем запустить наш скрипт, добавив разрешение allow-scripts в наш атрибут iframe.

sandbox="allow-scripts"

Как только вы это сделаете и обновите страницу, вы должны увидеть журнал консоли с каждой из наших страниц.

Всплывающие окна и модальные окна

Одна из проблем, о которых мы узнали, заключается в том, что iframe может открывать диалоговые окна в верхней части контекста просмотра и препятствовать взаимодействию пользователя со страницей. Чтобы проверить, можно ли это использовать в песочнице, давайте добавим предупреждение в наш клиентский скрипт.

alert("hello from the client")

Когда мы запускаем это, мы получаем следующую ошибку:

“Ignored call to ‘alert()’. The document is sandboxed, and the ‘allow-modals’ keyword is not set.”

Несмотря на то, что мы разрешаем запуск скриптов, песочница по-прежнему ограничивает многие действия. Чтобы оповещение работало из iframe, нам нужно добавить свойство allow-modals в iframe.

sandbox="allow-scripts allow-modals"

Имейте в виду, что это все или ничего. Мы не можем разрешить одни всплывающие окна и заблокировать другие. На мой взгляд, это нормальное ограничение, которое снимает нашу первую проблему безопасности iframe.

Навигация по окнам верхнего уровня и открытие новых вкладок

Наши вторая и третья проблемы безопасности связаны с переходом на страницу с исходного URL-адреса. Мы увидели, что iframe того же происхождения может перемещаться по странице без взаимодействия с пользователем, а iframe другого происхождения может делать это при взаимодействии с пользователем. Давайте попробуем это в нашей песочнице.

// client.js
function clickNav () {
  window.top.location = 'http://localhost:8001'
}
window.top.location = 'http://localhost:8001'

Это приводит к следующим ошибкам:

Здесь мы рассмотрели сразу два случая. Первоначальная команда для изменения местоположения не удалась, как и команда по щелчку. На самом деле есть отдельные разрешения, которые мы можем применить к нашему iframe для каждого из этих случаев. Мы можем разрешить любую навигацию с помощью allow-top-navigation и навигацию, активируемую пользователем, с помощью allow-top-navigation-by-user-activation.

sandbox="allow-scripts allow-top-navigation-by-user-activation"

Когда мы включаем это, iframe другого происхождения может перенаправлять страницу при действии пользователя. То же самое и для фреймов с одинаковым происхождением, где вы можете явно установить разрешения навигации, независимо от происхождения.

Файлы cookie и запросы браузера

Последняя проблема, которую необходимо решить, - это возможность получать доступ к файлам cookie и делать запросы с iframe того же происхождения. Давайте попробуем получить доступ к файлам cookie с помощью изолированного фрейма iframe.

// hosted-client.js
console.log(document.cookie)

В отличие от прошлого раза это приводит к следующей ошибке:

Uncaught DOMException: Failed to read the 'cookie' property from 'Document': The document is sandboxed and lacks the 'allow-same-origin' flag.

Точно так же, когда мы пытаемся сделать запрос с тем же кодом запроса, что и в предыдущем разделе, мы получаем другую ошибку.

Failed to load http://localhost:8000/: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Вышеупомянутая ошибка заключается в том, что браузер блокирует наш запрос, потому что у нас больше нет того же источника. Это связано с тем, что свойство песочницы устанавливает источник фрейма равным null, что означает, что теперь это будет запрос из другого источника, даже если фрейм iframe размещен в том же домене.

Добавление атрибута "песочница" allow-same-origin предотвратит возникновение обеих этих ошибок. Однако вы должны быть осторожны и убедиться, что у вас есть полный контроль над содержимым фрейма перед его использованием. Как отмечено в документации Mozilla iframe:

Когда встроенный документ имеет то же происхождение, что и главная страница, настоятельно не рекомендуется использовать одновременно allow-scripts и allow-same-origin, поскольку это позволяет встроенному документу программно удалить атрибут sandbox. Хотя это приемлемо, этот случай не более безопасен, чем неиспользование атрибута sandbox.

Вообще говоря, если вам нужны и allow-scripts, и allow-same-origin для вашей песочницы, вы должны спросить себя, почему вы в первую очередь используете iframing и уместно ли иметь свойство sandbox.

Собираем все вместе: как мы используем фреймы в Looker

В качестве примера практического использования этого в Looker мы используем фреймы, позволяющие клиентам создавать и запускать свои собственные пользовательские визуализации в нашем приложении.

Без какого-либо одного способа проверки каждой отдельной строки кода, которую наши клиенты могли бы написать для своих пользовательских визуализаций, нам нужно было создать безопасную среду выполнения для запуска кода. Диаграмму этой среды можно увидеть ниже.

Мы используем postMessage API для передачи данных и получения обратно любых событий или ошибок, которые создает визуализация. Учитывая ограничения изолированного iframe, он не может выполнять вызовы за пределами своего собственного фрейма, а также не может читать или изменять что-либо о родительской странице. Это позволяет нам быть уверенными в том, что и наше приложение, и данные наших клиентов в безопасности.

Заключение

Надеюсь, этот пост был вам полезен при рассмотрении вопросов безопасности, связанных с изолированными фреймами iframe. Пройдя через это руководство, вы должны лучше понять:

  • Как изолированные окна iframe с разрешением allow-modals могут потенциально препятствовать взаимодействию пользователя со страницей
  • Как изолированные iframe без свойств allow-top-navigation или allow-top-navigation-by-user-activation могут облегчить использование iframe того же происхождения, которые могут перенаправлять страницу верхнего уровня, а также iframe другого происхождения при некотором взаимодействии с пользователем.
  • Почему изолированные iframe без свойства allow-same-origin не позволяют iframe с одинаковым происхождением получать доступ к файлам cookie домена и выполнять запросы, как если бы они были хостом.

Если у вас есть какие-либо вопросы по этому руководству, не стесняйтесь обращаться в Сообщество Looker. А если вам интересно узнать больше о нашей команде Looker, ознакомьтесь с нашими открытыми позициями.

Важные примечания