nginx: маршрутизация по телу HTTP через client_body_early_read

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 достаточно знать, что ручка появилась, и не тащить её в прод из чужого сниппета.

Источники и ссылки


Комментарии загружаются…