Все мы помним предупреждение из прошлого декабря. Тогда разработчики Next.js в срочном порядке призвали всех обновляться — из-за возможности удалённого выполнения кода практически на любой версии. Шутка ли? А следом, буквально через пару дней, выяснилось, что покой нам только снится. Снова прилетело: отказ в обслуживании, обход прокси в middleware (и не один, а целых пять), подделка запросов на стороне сервера, межсайтовый скриптинг, отравление кэша. В общем, джекпот.

Давайте разбираться, что там произошло на самом деле, и почему это важно даже для тех, кто использует React без Next.js.

Краткий дайджест проблем

В свежем наборе уязвимостей числится целый ворох неприятностей. Перечислим основное:

  • Отказ в обслуживании (DoS) — несколько вариантов.
  • Множественные обходы прокси в middleware (до пяти штук).
  • Подделка запросов на стороне сервера (SSRF).
  • Межсайтовый скриптинг (XSS) — дважды.
  • Отравление кэша — тоже два варианта.

Самый громкий и опасный из списка — это DoS, который коренится не столько в самом Next.js, сколько в React. Причём для атаки даже не нужна аутентификация.

Как работает DoS-уязвимость

Представьте себе стандартную ситуацию. Браузер общается с сервером. В нормальном мире для обмена данными используют JSON. Вы вызываете JSON.parse (разумеется, обернув в try-catch, иначе сервер ляжет от первой же кривой строки). Всё просто и предсказуемо.

Но в мире React Server Components всё иначе. Клиент и сервер общаются с помощью специального формата, который начинается с символа доллара. Этот $ — маркер, за которым идёт управляющий символ, указывающий, какое действие нужно выполнить. В прошлой критической уязвимости (RCE) использовался вызов, который позволял превратить строку в настоящую функцию JavaScript — пользователь передавал код, а сервер послушно его исполнял. Было нехорошо.

На этот раз используется символ F (большая буква). Что она означает? Она говорит парсеру: «Здесь ссылка на объект, который уже есть на сервере». Система пытается восстановить этот объект, вызвать его, «оживить» (revive). А теперь внимание — начинается самое интересное.

Злоумышленник формирует специальное циклическое тело запроса. Он создаёт цепочку ссылок, где последний элемент указывает на первый. Получается кольцо. Сервер начинает обрабатывать этот объект:

  1. parseModel — входящая строка распознаётся.
  2. getOutlineModel — попытка получить уже разрешённую модель.
  3. initializeModelChunk — инициализация куска данных.
  4. reviveModel — оживление модели.
  5. И снова parseModelString — возврат к первому шагу.

Цикл замыкается. И сервер будет ходить по этому кругу до тех пор, пока не кончится стек или не истечёт время. Достаточно всего 53 тысяч итераций, чтобы вызвать переполнение стека на типичной машине. На дешёвых EC2-инстанциях, где стек меньше, процесс рухнет ещё быстрее.

Одно-единственное сообщение — и ваш сервер в облаках уходит в разнос. Процессор будет утилизирован на десятки секунд (если не больше), все остальные запросы встанут в очередь. JavaScript, как известно, однопоточен. Один тяжёлый запрос парализует всё приложение.

И это всё — из-за серверных компонентов?

Возникает закономерный вопрос: а зачем всё это нужно? Какой практический смысл в React Server Components, если они порождают такие дыры?

Основная идея — избавить разработчика от проблемы N+1 запросов и дать возможность кэшировать начальный HTML на уровне CDN. Первый ответ с сервера, содержащий оболочку, кэшируется. А всё, что связано с пользовательскими данными, подгружается потом. Звучит неплохо. Но цена за этот удобный кэш — гигантский слой инженерии, парсеров, ссылок, обходов графа. Всё это ради того, чтобы разработчику не пришлось думать о том, как именно загружать данные.

Можно ведь по старинке — взять и загрузить то, что действительно нужно. Никто не отменял простые решения.

Что ещё смешного?

Отдельного упоминания заслуживает уязвимость XSS. React всегда позиционировался как библиотека, которая избавляет от необходимости думать об экранировании HTML. Вы просто передаёте данные, а React безопасно их отрисовывает. Как оказалось, под капотом кое-где используется dangerouslySetInnerHTML. И там, где надо было экранировать символы, разработчики забыли это сделать. Ирония судьбы — библиотека, которая обещает безопасность по умолчанию, сама допускает классическую дыру.

Что делать?

Ответ один: обновляться. Даже если вы не используете Next.js, а работаете с чистым React, эта проблема (DoS через циклические ссылки) вас касается. Уязвимость живёт в слое, ответственном за парсинг ответов серверных компонентов. Старые версии React подвержены атаке.

В 2016 году многие считали React отличным решением. Простые приложения без легаси работали шустро, и это подкупало. Но потом пришёл опыт, пришло понимание цены абстракций. Иногда самый ценный урок — это умение вовремя сказать «нет» очередному слою сложности, особенно когда он открывает такие векторы атак.

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