YApi — платформа управления API от команды YMFE (Qunar): документация интерфейсов, мок-сервер на Mock.js, тестовые наборы с проверками и отчётами, импорт Swagger и Postman. Её ставят на свои серверы, чтобы бэкенд, фронтенд и тестировщики работали с одним описанием API. У проекта 27,7 тысячи звёзд на GitHub, а неофициальный Docker-образ скачали больше 600 тысяч раз. В Китае это стандартный инструмент — примерно как у нас связка Swagger и Postman.
Последний релиз, 1.12, вышел в ноябре 2022 года. С тех пор в репозитории висят 1629 открытых issue. Среди них отчёт об удалённом выполнении кода через мок-скрипты, на который никто не ответил, и десятки вопросов «cross-request больше не ставится, что делать?».
Я взялся продолжить проект под именем Yapix (GitHub, Apache-2.0). Он работает на существующей базе YApi, а обновление с настоящей базы YApi 1.12 проверяется в CI на каждое изменение. В статье — что сломалось за три года, что нашлось в коде по дороге и как это чинилось.
Что сломано в 2026 году
Проект, который никто не трогает, ломается не сам — ломается мир вокруг.
Node.js. YApi шифрует токены проектов через crypto.createCipher. В Node.js 22 эту функцию удалили. На новом Node сервер стартует, но всё, что связано с токенами (открытый API, автотесты из CI, плагины для IDE), перестаёт работать.
Сборка клиента. Фронтенд собирается ykit — обёрткой над webpack 1 — и node-sass 4. Ни то, ни другое на современном Node не ставится. Собранный бандл лежит прямо в репозитории, так что YApi можно запустить, но нельзя поменять в интерфейсе ни строчки.
npm-зеркала. package-lock.json ссылается на registry.npm.taobao.org и registry.nlark.com. Оба адреса больше не отдают пакеты: npm ci падает на первом же пакете. Лечится переписыванием адресов на registry.npmjs.org, хэши при этом остаются верными — тарболы те же.
Расширение браузера. Кнопка «运行» (запуск запроса) и тестовые наборы в браузере работают через расширение cross-request: страница не может сама отправить запрос на чужой домен из-за CORS. Расширение написано под Manifest V2, который Chrome больше не запускает. Из Chrome Web Store его давно убрали, лицензии у него нет.
Зависимости. По базе GitHub Advisory в production-зависимостях YApi 1.12 — 244 известные уязвимости, из них 50 критических. Среди них mongoose 5.7, vm2 и старый koa.
Токены, которые можно подделать
Разбираясь с createCipher, я прочитал, как вообще устроены токены проектов. Токен нужен для доступа к API без входа: его копируют в CI, в плагин IDE, в генератор TypeScript-типов.
В базе у каждого проекта хранится случайный токен из 20 символов. Пользователю выдаётся не он, а зашифрованная строка uid|токен_проекта: так сервер знает, от чьего имени пришёл запрос, и применяет права этого пользователя. Ключ шифрования берётся из passsalt в config.json, а если его там нет — из константы в коде:
const defaultSalt = 'abcde';
passsalt нет ни в примере конфига, ни в инструкциях по установке. Значит, почти во всех установках ключ — пять букв, которые лежат на GitHub.
Последствия прямые. Любой, у кого есть хотя бы гостевой доступ к проекту, получает свой токен. Раз ключ известен, по токену можно получить токен проекта и собрать токен с чужим uid: сервер проверяет только, что строка расшифровалась, и работает с правами указанного пользователя.
Как это исправлено в Yapix:
если в конфиге нет своего
passsalt, при первом запуске генерируется случайный секрет на 32 байта. Он хранится в базе, в отдельной коллекции;токены, которые расшифровываются только общеизвестным ключом, по умолчанию отклоняются с понятным сообщением «получите новый токен в настройках проекта»;
на время миграции их можно временно включить (
"legacyTokens": true), и каждое такое использование пишется в лог;если
passsaltв конфиге был задан, старые токены продолжают работать как есть.
Тем, кто остаётся на YApi, достаточно задать длинный случайный passsalt в config.json и перевыпустить токены.
Заодно выяснилось, что /api/project/token не проверял, имеет ли пользователь доступ к проекту: токен выдавался по любому project_id. Теперь проверяет.
Как заменить createCipher и не сломать старые токены
Токены, выданные с настоящим passsalt, должны продолжить работать. crypto.createCipher('aes192', password) превращает пароль в ключ и IV через функцию OpenSSL EVP_BytesToKey: MD5 в один проход, без соли. Её несложно повторить:
function deriveKeyAndIv(password) { const pass = Buffer.from(password, 'utf8'); const parts = []; let prev = Buffer.alloc(0); while (Buffer.concat(parts).length < 24 + 16) { prev = crypto.createHash('md5').update(Buffer.concat([prev, pass])).digest(); parts.push(prev); } const bytes = Buffer.concat(parts); return { key: bytes.subarray(0, 24), iv: bytes.subarray(24, 40) }; }
Дальше — обычный createCipheriv('aes-192-cbc', key, iv). Я сравнил результат с настоящим createCipher на Node 20 для нескольких паролей, включая кириллицу, — побайтно совпадает.
Песочница для скриптов
В YApi можно писать JavaScript в трёх местах: мок-скрипт меняет ответ мок-сервера, скрипт проверки в тестовом наборе проверяет ответ, а пред- и постскрипты запроса выполняются при автотестах. Всё это выполнялось на сервере: мок-скрипты — через safeify (обёртку над vm2), проверки и автотесты — через встроенный node:vm.
Документация Node прямо говорит, что node:vm — не механизм безопасности. vm2 заброшен автором после серии обходов песочницы. Любой, кто может редактировать мок-скрипт, а при открытой регистрации это кто угодно, может выполнить код на сервере. Об этом и говорит тот самый открытый отчёт.
В Yapix скрипты выполняются в isolated-vm — отдельном изоляте V8 со своей кучей. Устройство такое:
Новый изолят на каждый запуск. Лимит памяти — 64 МБ. После выполнения изолят уничтожается, так что запуски не видят друг друга.
Внутрь передаются только данные. Объекты (
mockJson,params,body,header) копируются как JSON. Функций хоста внутри нет вообще: ниrequire, ниprocess, ни файловой системы, ни сети.Всё, чем скрипты пользовались, живёт внутри изолята. Это
assert,log,MockиRandom,utilsс хэшами, base64, CryptoJS и jsrsasign, а такжеstorage. Библиотеки грузятся, только если скрипт их упоминает. Скомпилированный код кэшируется, и повторная загрузка Mock.js занимает миллисекунды.Результат возвращается обратно как JSON. Сервер берёт из него только нужные поля.
Время ограничено. Синхронная часть — 3 секунды процессорного времени, всё выполнение — 10 секунд. По дедлайну изолят уничтожается, даже если скрипт ждёт промис.
Интерфейс для скриптов не поменялся. Скрипты, которые писали под YApi, работают без правок, если не лезли в Node.js — а туда им лезть и не следовало. Проверки из тестовых наборов по-прежнему пишутся через assert.equal(status, 200): модуль assert я реализовал внутри изолята, с теми же сообщениями об ошибках.
Mongoose 5 → 9
Слой данных пришлось переписать почти целиком, хотя кода там немного. Вот что поменялось между версиями:
Model.remove()иModel.update()удалены. Важно, чтоremoveудалял все подходящие документы, аupdateбезmulti: true— только один. Значит, замены такие:deleteManyиupdateOne. Где стоялmulti, тамupdateMany.Колбэки убраны отовсюду. Плагин
mongoose-auto-increment, который выдаёт числовые id (у всех сущностей YApi id числовые), был целиком на колбэках. Я переписал его на промисы. Он пользуется той же коллекцией счётчиков, что и оригинал, поэтому новые id продолжают старые последовательности.strictQuery,useFindAndModifyи прочие флаги сменили значения по умолчанию. Их пришлось выставить явно, чтобы поведение запросов не поменялось.
Параметры с операторами MongoDB
В YApi параметры запроса часто попадают в фильтры MongoDB как есть. Если вместо строки прислать объект {"$ne": null}, поиск превращается в запрос с оператором. В Yapix все запросы к API проходят общий обработчик, и параметры с операторами MongoDB отклоняются там с кодом 400. $ref и $schema из JSON Schema этим не задеваются: блокируется только список настоящих операторов.
Пароли и вход
Первый администратор в YApi создавался с паролем
ymfe.org, и он был напечатан в документации. Теперь пароль берётся изYAPIX_ADMIN_PASSWORDили генерируется и показывается один раз.Пароли хранились как SHA-1 с солью. Теперь это scrypt. Старые хэши заменяются при следующем входе пользователя, и ничего делать не нужно.
Вход через LDAP подставлял логин в фильтр поиска без экранирования и принимал пустой пароль. Многие LDAP-серверы считают пустой пароль анонимным входом и пускают. Теперь логин экранируется по RFC 4515, а пустой пароль сразу отклоняется.
Клиент: webpack 5 и неожиданное «exports is not defined»
Сборку я заменил на webpack 5, Babel 7, less 4 для темы antd и dart-sass. Первый собранный бандл открылся белым экраном с ошибкой exports is not defined.
Оказалось, в коде YApi во многих файлах import соседствует с module.exports. Старый Babel с пресетом es2015 превращал всё в CommonJS, и такое смешение работало. Webpack 5 видит import и считает файл ES-модулем, а в ES-модуле нет module и exports. Помогло то же, что делал старый Babel: переводить весь свой код в CommonJS (modules: 'commonjs' плюс sourceType: 'unambiguous').
Интерфейс остался на antd 3 и React 16. Переход на актуальный antd означает переписать весь интерфейс, это отдельная большая работа. Но и так нашлось что убрать. При каждом открытии страницы администратором клиент ходил за списком версий на сторонний мок-сервис fastmock.site. Для интранет-установок это лишний запрос наружу, так что баннер обновлений я удалил.
Своё расширение вместо cross-request
Контракт расширения простой: на странице должна появиться функция window.crossRequest(options), которая отправляет запрос в обход CORS и вызывает success(body, headers, data) или error(...). Я написал расширение с нуля под Manifest V3, опираясь только на то, как его вызывает код YApi. Код cross-request без лицензии я не открывал.
Устроено оно из трёх частей:
page.jsв мире страницы определяетwindow.crossRequestи отправляет запрос черезpostMessage;bridge.jsв изолированном мире расширения передаёт его в service worker;service worker делает
fetch. У расширения есть host permissions, поэтому CORS его не ограничивает.
Главное отличие от оригинала — модель доверия. Функция, которая шлёт запросы с Cookie пользователя на любой адрес и возвращает ответы, — опасная вещь для любой страницы, где она есть. Поэтому скрипты регистрируются динамически (chrome.scripting.registerContentScripts) и только для сайтов, которые пользователь сам разрешил во всплывающем окне. Service worker ещё раз сверяет origin отправителя со списком, принимает только http(s) и ограничивает таймаут.
Раз контракт тот же, расширение работает и с оригинальным YApi 1.12. Я проверил это тем же автотестом: Chrome for Testing загружает расширение, разрешает сайт и нажимает «发送» на странице интерфейса.
Уязвимость, которую нельзя обновить
После обновлений в production-зависимостях осталась одна запись: prototype pollution в Mock.js. Исправленной версии нет — 1.1.0 последняя. А Mock.js — сердце мок-сервера, шаблоны для него пишут пользователи.
Уязвимость в функции Util.extend, которая рекурсивно копирует шаблон. Ключ proto из JSON-шаблона приводит её к Object.prototype, и дальше она пишет туда. Внутри Mock.js эта функция вызывается через общий объект Util. Поэтому хватает одного модуля-обёртки, который подменяет Util.extend на копию, пропускающую proto. Все места, где подключался mockjs, теперь подключают обёртку. В тестах есть проверка, что шаблон с proto больше не загрязняет прототип.
Как проверяется обновление
Главное обещание форка — «ваша база продолжит работать». Поэтому в CI есть отдельная задача:
Из истории репозитория берётся последний коммит YMFE (
git archive), зеркала в lock-файле переписываются на npmjs, запускается оригинальный YApi 1.12 на Node 20.Через его API создаются пользователь, группа, проект, интерфейсы, тестовый набор и токен.
YApi останавливается, на той же базе запускается Yapix на Node 24.
Тест проверяет, что пользователь входит со старым паролем, данные на месте и мок отвечает. Новые id должны продолжать старые последовательности, старый токен — отклоняться с понятной ошибкой, а новый — работать. Затем всё то же повторяется с
legacyTokens.
Ещё этот тест подтвердил неприятную деталь, которую я вписал в инструкцию по обновлению: после входа в Yapix пароль пересчитывается в scrypt, и вернуться на YApi без сброса паролей уже не получится.
Итог
YApi 1.12 | Yapix 2.0 | |
|---|---|---|
Node.js | до 20 | 24 LTS |
Скрипты | vm2/safeify, node:vm | отдельный изолят V8 с лимитами |
Токены без | подделываются | случайный ключ, старые отклоняются |
Уязвимости в production-зависимостях | 244 (50 критических) | 1, закрыта обёрткой |
Сборка клиента | не собирается | webpack 5 |
Расширение | MV2, не работает | MV3, только на разрешённых сайтах |
Попробовать можно в Docker (образ собран под amd64 и arm64):
curl -O https://raw.githubusercontent.com/Perruer/yapix/main/docker-compose.yml YAPIX_ADMIN_PASSWORD='длинный пароль' docker compose up -d
Если у вас работает YApi, прочитайте UPGRADING.md: там про версии MongoDB (нужна 4.4+), замену токенов и ограничения скриптов. Буду рад issue и отзывам — особенно от тех, кто переносит живую установку.

