15 сентября 2026 года Nick Shadrin опубликовал в блоге nginx запись NGINX Routing on HTTP Request Body. Это не новый пакет и не CVE. Это разбор директивы client_body_early_read из mainline 1.31.5: nginx читает тело HTTP до выбора location и уже по JSON может решить, куда отправить запрос.
Обычный WordPress за nginx и php-fpm эти ручки сами не включает. Типичный сайт ходит по URI: /, /wp-admin/, /wp-json/. Здесь речь про шлюз, где метод живёт внутри JSON-RPC или MCP, а путь у всех один, вроде POST /api/v1/rpc.
client_body_early_read в ngx_http_core_module
По умолчанию nginx разбирает запрос в фазах. Сначала строка запроса и заголовки, потом поиск подходящего location, и только после этого тело. Так быстрее: большинство URL обрабатываются, не трогая POST. Тело может быть чем угодно, от короткого JSON до загрузки медиа.
Проблема, которую описывает Shadrin, в другом слое. Протоколы поверх HTTP кладут свой method в JSON, а HTTP-метод при этом остаётся POST. Прокси видит URI и заголовки, но не видит поле tools/call или createOrder, пока не прочитает тело. Маршрутизация и лимиты оказываются слепыми.
Директива появилась в 1.31.5. Синтаксис в ngx_http_core_module: client_body_early_read string ...; контекст http и server, значения по умолчанию нет. Если хотя бы один параметр не пустой и не равен 0, тело читается сразу после заголовков, настройками из блока server.
В блоге прямо сказано: включить директиву в старом конфиге «просто так» смысла нет. Конфиг нужно собирать под этот порядок: тело уже есть, и уже по нему работают predicate location, map, if, njs или JSON-парсер.
Документация добавляет ограничения, которых в маркетинговом абзаце не будет. Директива несовместима с модулями без буфера тела, в том числе с ngx_http_grpc_module. Несовместима с модулями, которые пишут тело в файл, в том числе с ngx_http_dav_module. client_body_in_file_only при раннем чтении игнорируется.
location /test_body и if ($request_body)
Учебный фрагмент из блога. На порту 8000 тело читается всегда, а location смотрит уже на $request_body:
server {
listen 8000;
client_body_early_read 1;
location /test_body {
if ($request_body = "BAD_DATA") {
return 403 "Bad data\n";
}
return 200 "OK\n";
}
}
Проверка из той же записи. Подставьте адрес сервера, не копируйте SERVER_IP буквально:
curl -v -X POST -d "SIMPLE_DATA" SERVER_IP:8000/test_body curl -v -X POST -d "BAD_DATA" SERVER_IP:8000/test_body
Ожидание автора: первая команда даёт OK, вторая Bad data. Имеет смысл выключить директиву и повторить: без раннего чтения $request_body на этапе if ещё пустой. После правки конфига, как обычно, копия файла, nginx -t, затем reload, не restart «на всякий случай».
Это демонстрация, не образец для прода. if в nginx по-прежнему капризный инструмент. На рабочем шлюзе автор сразу переходит к JSONPath, map и predicate location.
json_set $coffee_method и location $restricted_methods
Продвинутый пример в записи: JSON-RPC POST /api/v1/rpc с полем method. Нужно отдельно вести createOrder и deleteOrder: лимит запросов и доступ только с localhost, остальной API без этих ограничений.
Три детали из 1.31.5, которые здесь складываются. Раннее чтение тела. Директива json_set (не путать с njs-овским js_set, это отдельное предупреждение блога). Predicate location: блок location срабатывает, если переменная не пустая и не 0. Сам синтаксис предикатов разбирался в разборе nginx 1.31.5 от 2 сентября.
http {
client_body_early_read 1;
json_set $coffee_method $request_body "method";
map $coffee_method $restricted_methods {
"~createOrder" 1;
"~deleteOrder" 1;
default 0;
}
limit_req_zone $remote_addr zone=restricted:7m rate=3r/s;
server {
location $restricted_methods {
limit_req zone=restricted;
allow 127.0.0.1;
deny all;
proxy_pass http://api.coffeeshop.local;
}
location /api/v1/ {
proxy_pass http://api.coffeeshop.local;
}
}
}
JSONPath в примере короткий: селектор "method" из тела. map ловит значение regex, чтобы отрезать лишние символы по краям. Дальше переменная кормит predicate location. URI при этом может быть одним и тем же.
Копировать этот фрагмент на VPS с WordPress нельзя как есть. api.coffeeshop.local учебный. deny all с allow 127.0.0.1 отрежет внешние клиенты у «опасных» методов, это задумано. Свой апстрим, свои методы и своя зона limit_req должны появиться до reload.
js_set, js_access и r.readRequestJSON()
До 1.31.5 тело в njs читали скриптом, решение принимали внутри JS, потом уходили во внутренний location. Shadrin перечисляет три варианта и честно пишет, какие из них не годятся для маршрутизации.
Первый: js_set и чтение r.variables.request_body через r.requestText или r.requestBuffer на content-фазе или позже. Для выбора location уже поздно. Плюс js_set не умеет асинхронные операции: таймауты, подзапросы, fetch().
Второй: js_access и асинхронное r.readRequestText() / r.readRequestJSON(). Директива живёт только в контексте location. Значит location уже выбран.
Третий: включить client_body_early_read и через js_set читать переменную request_body на любой фазе. В блоге это названо самым простым способом гонять скрипт по payload до решения о маршруте. Lua автор оставляет за скобками.
На практике это развилка. Если нужен только JSONPath в одну переменную, хватает json_set без njs. Если логика сложнее одного поля, раннее чтение плюс js_set. Старый js_access по-прежнему полезен внутри уже выбранного location, но шлюзом он не станет.
map $http_content_type $is_json на VPS
Читать тело раньше location стоит памяти и CPU. Магии нет, это формулировка блога. Смотреть процесс можно обычным ps или top. Число worker лучше держать по числу ядер, которые реально отданы машине, а не «побольше на всякий случай».
Глобальный client_body_early_read 1; на WordPress-VPS я бы не ставил. Комментарии, Gutenberg, REST и загрузки медиа пойдут через раннее чтение даже там, где маршрутизация по JSON не нужна. Автор предлагает включать директиву условно.
map $http_content_type $is_json {
application/json 1;
}
server {
client_body_early_read $is_json;
}
Либо по URI, если тело нужно только на одном endpoint:
map $uri $is_uploads {
/api/v1/uploads 1;
}
server {
client_body_early_read $is_uploads;
}
Лимиты в учебном примере жёсткие: client_max_body_size 256; и client_body_buffer_size 256;. В nginx число без суффикса это байты. Для JSON-RPC на шлюзе так и задумано. На сайте с загрузкой картинок в медиатеку такие значения отрежут POST. Свои лимиты нужно считать по трафику, а не копировать 256 с кофейного API.
Пакет. 1.31.5 это mainline с nginx.org, не сборка из Ubuntu или Debian. Стабильная линейка на момент релиза 1.31.5 была 1.30.x. Дистрибутивный nginx на типичном VPS эту директиву просто не знает: nginx -t скажет unknown directive. Сначала версия бинарника, потом конфиг.
nginx -v sudo nginx -t sudo systemctl reload nginx
Если в том же server есть grpc_pass или WebDAV, ранняя схема из документации не собирается. Для обычного fastcgi_pass на php-fpm директива не вредна сама по себе, просто никому не нужна: WordPress и так выбирает PHP по URI.
Имеет смысл трогать это на стенде, если nginx уже стоит как шлюз JSON-RPC, MCP или своего API, и решение «куда проксировать» нельзя принять по пути и заголовку. Для лендинга и блога за php-fpm достаточно знать, что ручка появилась, и не тащить её в прод из чужого сниппета.