Когда мы начинали работать над проектом, нам очень хотелось сделать интерактивные песочницы для кода — такие, в которых можно быстро проверить 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 на некорректные выражения.
Общие принципы изоляции
Вот что объединяет все восемь песочниц:
-
Никакой серверной обработки. Весь код выполняется в браузере. Мы не проксируем запросы и не имеем доступа к пользовательским данным.
-
Web Worker для тяжёлого. JS, Python, SQL — всё, что может нагрузить основной поток или выполняет пользовательский код, вынесено в воркеры.
-
Таймауты везде. Каждая песочница имеет лимит по времени выполнения. При превышении — принудительный
terminateворкера или возврат ошибки. -
Пересоздание на каждый запуск. Мы не переиспользуем воркеры между запусками. Каждый Run создаёт новый Worker, выполняет код и уничтожается. Это исключает утечки состояния и атаки через остаточные данные.
-
Лимиты по объёму. Каждая песочница имеет ограничение на количество символов во входном коде — от 100 000 (Python) до 200 000 (JS, SQL). Это защита от намеренного переполнения памяти.
-
Валидация до выполнения. Все входные данные проверяются на пустоту и длину до того, как попасть в рантайм. Это отсекает очевидные атаки на этапе фронтенда.
Что мы вынесли из этого опыта
Самописный 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/