8 песочниц в браузере без Docker: как мы изолировали выполнение кода на клиенте

от автора

Когда мы начинали работать над проектом, нам очень хотелось сделать интерактивные песочницы для кода — такие, в которых можно быстро проверить JavaScript-сниппет, прогнать SQL-запрос или поэкспериментировать с Python. Бесплатно, без рекламы и без регистрации. Просто открываешь и пользуешься. Никаких «доступно только в Pro-версии» или «вы исчерпали лимит запусков».
Но как только мы начали проектировать архитектуру, стало понятно, что желание красивое, а реальность — ящик Пандоры. Дать пользователю возможность выполнять произвольный код прямо в браузере — значит открыть дверь для целого букета проблем: бесконечные циклы, XSS-векторы, попытки доступа к DOM, утечки памяти, вредоносные запросы. Каждый пункт по-своему опасен, и каждый нужно как-то закрывать.

При этом мы принципиально не хотели использовать серверные рантаймы. Никаких Docker-контейнеров, никаких выделенных виртуалок, никакого проксирования кода на бэкенд. Всё должно работать на клиенте — быстро, приватно и без затрат на инфраструктуру.

Это означало, что нам нужна система изоляции, которая живёт полностью в браузере. Причём для каждой среды — JavaScript, Python, SQL, HTML/CSS, Markdown, Bash, Regex, Cron — требуется свой подход. Где-то хватает Web Worker, где-то нужен WebAssembly с кастомной загрузкой, а где-то приходится писать эмулятор с нуля.

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

JavaScript: Web Worker и ручной таймаут

JavaScript-песочница — самая очевидная и одновременно самая опасная. Дать пользователю выполнить произвольный код в основном потоке — значит разрешить ему дотянуться до DOM, window, куки и всего остального. Поэтому первое и главное решение: код летит в отдельный Web Worker.

У воркера нет доступа к DOM, document и window основного потока. Но остаются две проблемы: как перехватить вывод console.log и как защититься от бесконечного цикла.

Перехват консоли. Вместо того чтобы глушить вывод совсем, мы подменяем console.log, error, warn и info внутри воркера. Каждый вызов сериализуется в строку и складывается в массив logs. Когда код отработал — массив улетает в основной поток через postMessage:

const logs: string[] = [];const cons = {  log: (...a: unknown[]) => {    logs.push(a.map((x) =>      typeof x === "object" ? JSON.stringify(x) : String(x)    ).join(" "));  },  error: (...a: unknown[]) => {    logs.push("[error] " + a.map((x) =>      typeof x === "object" ? JSON.stringify(x) : String(x)    ).join(" "));  },  // ...warn, info аналогично};

Подменённый console передаётся в пользовательский код как аргумент — через new Function("console", ...). Никаких глобальных переменных не трогаем, код изолирован на уровне замыкания.

Бесконечный цикл. Воркер — это отдельный поток, так что UI не зависнет в любом случае. Но сам воркер может висеть вечно, и нам нужно уметь его прибивать. Решение — setTimeout с таймаутом, который триггерит terminate:

const timer = setTimeout(() => finish({ kind: "timeout" }), timeoutMs);

Как только таймаут срабатывает — воркер отправляет сообщение { kind: "timeout" } и сбрасывает флаг finished, чтобы последующие вызовы finish игнорировались. Пользовательский код выполняется через async/await внутри замыкания — если он завис в синхронном бесконечном цикле, то до await дело не дойдёт, но таймаут всё равно сработает и воркер будет уничтожен снаружи вызовом terminate.

Есть нюанс: бесконечный цикл внутри setTimeout или Promise, созданного пользователем, таймаут не перехватит — но сам воркер уже будет terminated к тому моменту.

Лимиты: 200 000 символов на код, таймаут 10 секунд. Воркер создаётся заново на каждый запуск — никакого переиспользования, чтобы исключить утечки состояния.

Python: Pyodide с цепочкой зеркал

Python-песочница построена на Pyodide — CPython, скомпилированном в WebAssembly. И в этом главная проблема: Pyodide — это здоровенный Wasm-файл, и его нужно откуда-то грузить.

Fallback по зеркалам. Мы не стали завязываться на один CDN. Вместо этого определили три источника и пробуем их по очереди:

const INDEX_CANDIDATES = [  "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/",  "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/",  "https://unpkg.com/pyodide@0.26.1/full/",];

Функция pickWorkingIndexBase делает HEAD-запрос к pyodide.js каждого зеркала и возвращает первое доступное. Только после этого вызывается importScripts и loadPyodide. Это работает в Web Worker — основной поток вообще не участвует в загрузке.

Изоляция. Pyodide из коробки не даёт доступа к файловой системе хоста. Стандартная библиотека доступна — json, re, math и так далее. Но import os не сработает, потому что нет реальной операционной системы под капотом. Никакого pip тоже нет — только то, что уже вкомпилировано в Pyodide.

Перехват вывода. Pyodide предоставляет методы setStdout и setStderr. Мы вешаем на них batched-колбэки, которые собирают вывод в строку. Важный нюанс: Pyodide может слать несколько print в одном батче, без \n между ними. Поэтому мы дописываем перевод строки после неполных чанков:

const appendLine = (buf: string, s: string) => {  if (!s) return buf;    return buf + (s.endsWith("\n") ? s : `${s}\n`);};

Таймаут. Реализован так же, как в JS-песочнице: setTimeout на нужное количество миллисекунд плюс запас на тормоза Wasm. При срабатывании отправляется { type: "run-timeout" }. Плюс есть специальное сообщение { type: "cancel" }, которое сбрасывает текущий таймер — на случай, если пользователь запустил код заново, не дожидаясь завершения предыдущего.

Лимиты: 100 000 символов на код, таймаут 25 секунд (Python в Wasm заметно медленнее нативного JS).

SQL: SQL.js в Web Worker с ручным locateFile

Для SQL-песочницы мы выбрали SQL.js — SQLite, скомпилированный в WebAssembly. Работает внутри Web Worker, чтобы не нагружать основной поток при парсинге тяжёлых запросов.

Загрузка Wasm без CDN. sql.js по умолчанию пытается загрузить wasm-файл с CDN (sql.js.org). Это не подходит: во-первых, лишняя зависимость от внешнего источника, во-вторых — на sql.js.org нет файла sql-wasm-browser.wasm, который запрашивает бандл (там лежит HTML вместо wasm, и движок падает с ошибкой). Поэтому мы переопределяем locateFile:

function locateSqlWasm(file: string): string {  const origin = self.location?.origin;    if (!origin || !file.endsWith('.wasm')) {    return `https://sql.js.org/dist/${file}`;  }    if (file.includes('sql-wasm-browser')) {    return `${origin}/wasm/sql-wasm-browser.wasm`;  }    return `${origin}/wasm/sql-wasm.wasm`;}

Wasm-файлы раздаются с того же origin, что и страница. Никаких внешних CDN — быстрее и надёжнее.

Инициализация базы. Каждый запуск начинается с инициализации: создаётся новый экземпляр Database, накатывается схема из пользовательского ввода:

db = new S.Database();if (msg.payload.schemaSql.trim()) {  db.run(msg.payload.schemaSql);}

Это позволяет пользователю создать таблицы, вставить тестовые данные и сразу писать запросы к ним.

Выполнение запросов с AbortController. Главная опасность SQL-песочницы — запрос, который вешает базу (например, cross join на больших таблицах или рекурсивный CTE). sql.js выполняет запросы синхронно, и просто setTimeout тут не поможет. Поэтому мы используем AbortController:

const ac = new AbortController();const timer = setTimeout(() => ac.abort(), timeoutMs);const results = db.exec(sql);if (ac.signal.aborted) {  post({ type: "error", payload: { message: "Превышен таймаут..." } });  return;}

После выполнения проверяем, не был ли сигнал прерван за время работы db.exec. Если был — возвращаем ошибку таймаута.

Вывод результатов. Если запрос возвращает данные (SELECT), мы отдаём их как { kind: "select", columns, rows }. Если это INSERT/UPDATE/DELETE — возвращаем { kind: "ok", message }. Если ошибка — { kind: "error", message }.

Лимиты: 200 000 символов на скрипт (схема + запрос), таймаут 5 секунд.

HTML/CSS: iframe с sandbox и динамический srcdoc

HTML/CSS-песочница стоит особняком. Здесь нет выполнения кода на серверном рантайме — пользователь просто пишет разметку и стили, а мы должны безопасно показать результат.

Изоляция через iframe sandbox. Превью рендерится в <iframe sandbox="allow-scripts">. Атрибут sandbox включает изоляцию, а allow-scripts разрешает выполнение JavaScript внутри превью, но без доступа к cookie и DOM родительской страницы. Даже если пользователь напишет <script>document.cookie</script>, он не получит сессионные данные Halfcoder.

Сборка srcdoc. Пользователь вводит HTML и CSS отдельно. Мы собираем из них полноценный документ:

function buildSrcDoc(html: string, css: string): string {  const style = css.trim() ? `<style>${css}</style>` : "";    if (/<html[\s>]/i.test(html)) {    if (/<head[\s>]/i.test(html)) {      return html.replace(/<\/head>/i, `${style}</head>`);    }        return html.replace(/<html[^>]*>/i, (m) => `${m}<head>${style}</head>`);  }    return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${html}</body></html>`;}

Готовый документ передаётся в srcDoc атрибут iframe. Обновление происходит с задержкой 500 мс через debounce — чтобы не перерендеривать превью на каждое нажатие клавиши.

Ограничения. Песочница не проверяет CSS-инъекции — теоретически можно написать стиль, который сломает вёрстку внутри iframe, но наружу это не выйдет. JavaScript внутри превью выполняется в отдельном контексте и не может выйти за пределы iframe.

Markdown: DOMPurify с жёстким вайтлистом

Markdown-песочница, на первый взгляд, самая безобидная. Пользователь пишет Markdown, мы рендерим HTML. Опасность — XSS через встроенный HTML или скрипты, замаскированные под Markdown-разметку.

Санитизация через DOMPurify. После рендеринга через marked, получившийся HTML прогоняется через DOMPurify с жёстко заданным списком разрешённых тегов:

function sanitizeMarkdownHtml(raw: string): string {  return DOMPurify.sanitize(raw, {    USE_PROFILES: { html: true },    ALLOWED_TAGS: [      "p", "br", "strong", "em", "code", "pre",      "h1", "h2", "h3", "h4",      "ul", "ol", "li", "blockquote", "a",      "hr", "table", "thead", "tbody", "tr", "th", "td",    ],    ALLOWED_ATTR: ["href", "title", "class"],    ALLOW_DATA_ATTR: false,  });}

Никаких скриптов, никаких on*-обработчиков, никаких data-атрибутов. Даже если пользователь вставит <script>alert("xss")</script> в Markdown, DOMPurify вырежет это на этапе санитизации.

Почему не просто экранировать HTML. Мы могли бы вообще запретить HTML в Markdown, но это сломало бы поддержку таблиц, ссылок и других расширенных фич GFM. DOMPurify даёт золотую середину: HTML работает, но опасные элементы вырезаются.

Bash: самописный эмулятор с пайпами

Bash-песочница — возможно, самая необычная часть системы. Мы не стали подключать настоящий shell через Wasm или эмулятор терминала. Вместо этого написали свой парсер команд и эмулятор пайпов с нуля.

Почему самописный. Готовые решения вроде web-shell или jslinux слишком тяжёлые и заточены под полноценную ОС. Нам нужен был учебный терминал, который поддерживает базовые команды и пайпы, но не требует виртуализации.

Парсинг аргументов. Команды парсятся с учётом кавычек и экранирования:

function splitArgs(line: string): string[] {  const out: string[] = [];    let cur = "";  let q: '"' | "'" | null = null;    for (let i = 0; i < line.length; i++) {    const c = line[i];        if (q) {      if (c === q) { q = null; } else { cur += c; }      continue;    }        if (c === '"' || c === "'") { q = c as '"' | "'"; continue; }        if (/\s/.test(c)) {      if (cur.length) { out.push(cur); cur = ""; }      continue;    }        cur += c;  }    if (cur.length) out.push(cur);    return out;}

Пайпы. Главная фича эмулятора — поддержка пайпов. Строка разбивается по |, каждый сегмент выполняется отдельно, stdout предыдущего становится stdin следующего:

const segments = raw.split("|").map(s => trim(s));let stdin = "";for (let i = 0; i < segments.length; i++) {  const { out, env: ne } = runSegment(segments[i], stdin, env);    e = ne;    if (i === segments.length - 1) outs.push(out);  else stdin = out.endsWith("\n") ? out : `${out}\n`;}

Поддерживаемые команды. На старте их восемь: echo, export, pwd, wc, grep, head, tail, cat. Каждая реализована как чистая функция:

function cmdEcho(args: string[]): string {  return args.join(" ");}function cmdWc(args: string[], stdin: string): string {  const text = stdin.replace(/\n$/, "");  const lines = text.length ? text.split("\n").length : 0;  const words = text.trim().length ? text.trim().split(/\s+/).length : 0;  const bytes = new TextEncoder().encode(stdin).length;    if (args[0] === "-l") return String(lines);  if (args[0] === "-w") return String(words);  if (args[0] === "-c") return String(bytes);    return `${lines} ${words} ${bytes}`;}

Переменные окружения. export работает с реальным объектом Env, который передаётся между командами. Поддерживается подстановка переменных через $VAR:

function expandEnv(s: string, env: Env): string {  return s.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name: string) => env[name] ?? "");}

Ограничения. Никаких системных вызовов, никакого доступа к файловой системе (кроме виртуальной PWD = ‘/sandbox’). Незнакомые команды возвращают ошибку с подсказкой. Скрипты с переводом строки выполняются построчно, как в настоящем shell.

Regex: нативный JavaScript с защитой от ReDoS

Regex-песочница — самая простая технически. Мы не стали изобретать велосипед и используем нативный RegExp движок браузера. Но есть один важный нюанс — ReDoS.

Ограничение совпадений. При глобальном поиске мы насильно ограничиваем количество итераций до 5000:

let m: RegExpExecArray | null;const copy = new RegExp(pattern, flagStr);while ((m = copy.exec(haystack)) !== null) {  matches.push({ index: m.index, match: m[0], groups: m.slice(1) });    if (m[0].length === 0) copy.lastIndex++;  if (matches.length > 5000) break; }

Плюс ручная защита от пустых совпадений — если длина совпадения 0, мы сдвигаем lastIndex на 1, чтобы цикл не завис.

Примеры с ReDoS. В тестовых данных есть специальный пример infinite с паттерном a и строкой из 5000 символов — это позволяет проверить, что песочница не зависает на реальном ReDoS-векторе.

Cron: парсинг и визуализация без сервера

Cron-песочница — это не выполнение кода, а инструмент для работы с cron-выражениями. Пользователь вводит выражение и получает ближайшие запуски с учётом часового пояса.

Парсинг через cron-parser. Используем cron-parser v5 с поддержкой стандартных cron-выражений:

export function parseCronExpression(expression: string, options?: { currentDate?: Date; tz?: string }) {  return CronExpressionParser.parse(expression.trim(), {    currentDate: options?.currentDate ?? new Date(),    ...(options?.tz ? { tz: options.tz } : {}),  });}

Человекочитаемое описание. cronstrue генерирует текстовое описание выражения (например, «Каждые 15 минут»). Даты форматируются через Luxon с учётом часового пояса пользователя.

Изоляция. Здесь нет песочницы в классическом смысле — cron-парсер работает синхронно на клиенте и не выполняет пользовательский код. Единственная защита — try/catch на некорректные выражения.

Общие принципы изоляции

Вот что объединяет все восемь песочниц:

  1. Никакой серверной обработки. Весь код выполняется в браузере. Мы не проксируем запросы и не имеем доступа к пользовательским данным.

  2. Web Worker для тяжёлого. JS, Python, SQL — всё, что может нагрузить основной поток или выполняет пользовательский код, вынесено в воркеры.

  3. Таймауты везде. Каждая песочница имеет лимит по времени выполнения. При превышении — принудительный terminate воркера или возврат ошибки.

  4. Пересоздание на каждый запуск. Мы не переиспользуем воркеры между запусками. Каждый Run создаёт новый Worker, выполняет код и уничтожается. Это исключает утечки состояния и атаки через остаточные данные.

  5. Лимиты по объёму. Каждая песочница имеет ограничение на количество символов во входном коде — от 100 000 (Python) до 200 000 (JS, SQL). Это защита от намеренного переполнения памяти.

  6. Валидация до выполнения. Все входные данные проверяются на пустоту и длину до того, как попасть в рантайм. Это отсекает очевидные атаки на этапе фронтенда.

Что мы вынесли из этого опыта

Самописный Bash — это боль. Парсинг пайпов, экранирование спецсимволов, обработка краевых случаев с кавычками — на это ушло непропорционально много времени. Но готовых лёгких альтернатив мы не нашли, а тащить полноценный эмулятор терминала не хотелось.

Pyodide — тяжёлый, но безальтернативный. Загрузка Wasm-файла занимает время, особенно на мобильных. Но без Pyodide мы не смогли бы дать пользователю настоящий Python в браузере. Fallback по зеркалам спасает от проблем с CDN.

SQL.js и AbortController — опасная связка. db.exec выполняется синхронно, и AbortController не может прервать его посередине. Мы можем только проверить флаг после выполнения. Это значит, что запрос, который вешает SQLite на 10 секунд, реально вешает воркер на 10 секунд — таймаут только сигнализирует об этом постфактум.

DOMPurify — мастхев. Без него Markdown-песочница была бы дырой в безопасности. Вайтлист тегов — это не паранойя, а необходимость, когда рендеришь пользовательский HTML.

Web Worker — серебряная пуля для JS. Просто, надёжно, отлично изолирует. Жаль только, что нельзя заставить пользователя не писать бесконечные циклы внутри setTimeout — но это уже не наша проблема.

Обратная связь и полезные ссылки

Описанные песочницы — часть проекта Halfcoder, набора из 167+ инструментов для разработчиков, который целиком работает в браузере. Если хотите посмотреть на них вживую или потестировать — заходите на halfcoder.ru.

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

Ещё я веду телеграм-канал Сизиф IT, где пишу про IT-новости, делюсь полезными инструментами и иногда кидаю мемы.

ссылка на оригинал статьи https://habr.com/ru/articles/1051752/