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 есть отдельная задача:

  1. Из истории репозитория берётся последний коммит YMFE (git archive), зеркала в lock-файле переписываются на npmjs, запускается оригинальный YApi 1.12 на Node 20.

  2. Через его API создаются пользователь, группа, проект, интерфейсы, тестовый набор и токен.

  3. YApi останавливается, на той же базе запускается Yapix на Node 24.

  4. Тест проверяет, что пользователь входит со старым паролем, данные на месте и мок отвечает. Новые id должны продолжать старые последовательности, старый токен — отклоняться с понятной ошибкой, а новый — работать. Затем всё то же повторяется с legacyTokens.

Ещё этот тест подтвердил неприятную деталь, которую я вписал в инструкцию по обновлению: после входа в Yapix пароль пересчитывается в scrypt, и вернуться на YApi без сброса паролей уже не получится.

Итог

YApi 1.12

Yapix 2.0

Node.js

до 20

24 LTS

Скрипты

vm2/safeify, node:vm

отдельный изолят V8 с лимитами

Токены без passsalt

подделываются

случайный ключ, старые отклоняются

Уязвимости в 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 и отзывам — особенно от тех, кто переносит живую установку.