Skip to content

Запросы и ответы HTTP

HTTP-фронт Rapira построен на Pingora и входит в состав того же бинарника. Он принимает соединения на сокете, который открыл мастер-процесс, разбирает запрос, отдаёт его PHP и пишет обратно всё, что PHP выдал. Никакого апстрима за ним нет: на каждый запрос отвечает здесь же ваш собственный код.

На этой странице разобраны те места, где перевод между HTTP и PHP получается не один в один: какое поле заголовка попадает в какой ключ $_SERVER, что будет, если клиент пришлёт одно и то же поле дважды, насколько большим может быть тело запроса и как размечаются границы вашего ответа на выходе.

Фронт принимает открытый HTTP, без шифрования. Нужен TLS — терминируйте его на прокси перед Rapira; об этом рассказывает раздел Запуск в продакшене.

От имени заголовка к ключу $_SERVER

Поля запроса попадают в скрипт по одному-единственному правилу CGI: имя переводится в верхний регистр, каждый - меняется на _, а спереди дописывается HTTP_ (RFC 3875 §4.1.18). Так X-Forwarded-For становится HTTP_X_FORWARDED_FOR — этот ключ и читает ваш код.

Регистрируя переменную, PHP добавляет к этому собственное преобразование: точка тоже превращается в _. Два правила, и оба схлопывают разные символы в одно и то же подчёркивание, — в итоге три разных имени, пришедших по сети, оказываются в одном ключе:

В запросеВ PHP
X-Forwarded-For$_SERVER['HTTP_X_FORWARDED_FOR']
X_Forwarded_For$_SERVER['HTTP_X_FORWARDED_FOR']
X.Forwarded.For$_SERVER['HTTP_X_FORWARDED_FOR']

Такое совпадение имён — это уязвимость. Пусть доверенный прокси перед Rapira выставляет X-Forwarded-For. Клиент, приславший X_Forwarded_For, попадёт в тот же ключ $_SERVER, а фильтр заголовков на прокси вырезает написание через дефис и вариант с подчёркиванием попросту не видит. В итоге значение задаёт клиент, а приложение принимает его за значение от прокси.

Имена, конфликтующие с CGI-переменной

Поэтому Rapira проверяет имена полей запроса раньше, чем их увидит любой другой слой. Имя проходит, если каждый его байт входит в [A-Za-z0-9-]. Конфликт создают _ и .: оба схлопываются в тот же ключ $_SERVER, что и написание через дефис. Но правило устроено как список разрешённых символов, а не как запрет этих двух байтов, поэтому отклоняется и легальный, но необычный символ вроде ~, а сама проверка останется верной, если любое из двух преобразований когда-нибудь расширят. Что делать с отклонённым именем, решает http.unsafe_field_names:

  • drop (по умолчанию) — поле удаляется до того, как его увидит PHP, и каждое удаление пишется в лог с уровнем warn в цель http.
  • reject — запрос получает 400 и не обслуживается вовсе.
toml
[http]
unsafe_field_names = "drop"

Третьего варианта, который выключал бы проверку, нет, как нет и исключения для отдельного имени: совпадение имён, от которого проверка защищает, — это уязвимость. Где этот ключ стоит среди остальных настроек, показывает Конфигурация.

Если ваши клиенты законно присылают имя с подчёркиванием, лечится это переименованием в написание через -. Поля самого прокси проверка обрабатывает точно так же: отличить поле с подчёркиванием, выставленное доверенным прокси, от клиентской подделки Rapira не может, поэтому X_Forwarded_For, выставленный прокси, удаляется ещё до запуска PHP. Прокси перед Rapira делает такую замену одной строкой собственной конфигурации, и дальше имя становится обычным и проходит нетронутым.

drop пишет про каждое удаление с уровнем warn, но по умолчанию уровень логирования — error, поэтому этих строк вы не увидите, пока его не поднимете. Если заголовок неожиданно пропал из $_SERVER, поднимите уровень и первым делом смотрите цель http; как это сделать, рассказывает Логирование.

Поля, присланные несколько раз

HTTP разрешает клиенту повторить поле, а в CGI на переменную приходится ровно одно значение, поэтому повторы нужно свернуть в одно значение ещё до того, как PHP хоть что-то увидит. Rapira сворачивает их так, как позволяет грамматика самого поля:

  • Списочные поля — значения склеиваются через , : именно такую сборку RFC 9110 §5.3 разрешает для поля, определённого как список через запятую. Две строки Accept превращаются в text/*, image/*.
  • Cookie — тоже список, но не через запятую. Повторы склеиваются через ; , в том виде cookie-строки, которого ждёт парсер PHP, поэтому $_COOKIE собирается правильно.
  • Поля с единственным значениемAuthorization, Proxy-Authorization, Content-Type, Content-Length, Referer и From сохраняют только первую строку, а лишние отбрасываются с записью warn. Склейка их бы испортила: второй Authorization, свёрнутый в первый, окажется прямо внутри учётных данных, которые PHP собирается декодировать из base64. Повторный Content-Length получает 400 ещё до свёртки, так что до этого правила доходят только остальные пять полей.
  • Host — если строк Host больше одной, запрос получает 400, и никакой свёртки здесь не бывает. RFC 9112 §3.2 требует этого через MUST, а правильно ответить может только тот слой, который терминирует соединение.

Значения полей доходят до PHP сырыми байтами, от начала и до конца. Cookie в latin1 или подписанный заголовок сохраняют каждый октет ровно таким, каким его прислал клиент: конвертация в UTF-8 по дороге испортила бы как раз те значения, которые меняться не должны.

Тела запросов

Тело запроса читается в память до запуска PHP, а http.max_body_size_mb ограничивает, сколько его Rapira держит в памяти. По умолчанию это 8 МиБ — та же величина, что и у собственного post_max_size в PHP. Тело сверх лимита получает 413, и, поскольку остаток всё ещё идёт по сети, такой ответ заодно закрывает соединение, а не пытается использовать его повторно.

Лимит проверяется дважды:

  • По заявленному Content-Length — ещё до того, как прочитан хотя бы один байт тела.
  • Повторно, пока тело приходит, кусок за куском. Chunked-запрос не объявляет длину заранее, так что именно вторая проверка ограничивает расход памяти.

Для запросов HTTP/1.1 Rapira соблюдает Expect: 100-continue: сервер отправляет промежуточный 100 Continue, и клиент досылает тело, которое придерживал. Здесь важен порядок: проверка Content-Length идёт первой, поэтому клиент, объявивший слишком большое тело, получит 413 ещё до того, как что-нибудь загрузит. У запроса HTTP/1.0 такое ожидание игнорируется — так требует RFC 9110 §10.1.1.

toml
[http]
max_body_size_mb = 8

Как ответ уходит наружу

Всё, что пишет PHP, копится в буфере до конца запроса, и только потом заголовки ответа уходят в сеть. Ради этого буферизация и нужна: сервер знает точную длину тела и может отдать настоящий Content-Length. Без явных границ тела у HTTP/1.1 остаётся единственный способ их обозначить — закрыть соединение, а значит, на каждый запрос понадобится новое. С Content-Length работает keep-alive, и соединение остаётся живым.

Поэтому границы тела определяет сервер, а не PHP. Content-Length или Transfer-Encoding, которые выставил ваш код, отбрасываются и заменяются реальным размером буфера, так что устаревшая длина уже никогда не рассинхронизирует соединение. Ответы, у которых тела нет по определению, — 204 и 304 — не получают Content-Length вовсе.

Hop-by-hop-поля относятся к конкретному соединению, а не к ответу, поэтому выставлять их PHP тоже не разрешено (RFC 9110 §7.6.1). Из всего, что выдал ваш код, вырезаются:

Connection, Keep-Alive, Upgrade, Trailer, TE, Proxy-Connection, а также два поля, задающие границы тела, — Content-Length и Transfer-Encoding.

Если PHP всё-таки отдаёт заголовок Connection, перечисленные в нём поля вырезаются тоже — ровно это значение Connection и означает, — причём удаление происходит раньше, чем Rapira подставит собственный Content-Length. Поэтому Connection: content-length не удалит из ответа поля, задающие границы тела.

Всё остальное уходит таким, каким его написал PHP, вместе с повторами: Set-Cookie, Vary и Link вполне законно встречаются по несколько раз, и отправляются они все. Заголовок, который вообще невозможно представить в сетевом виде, отбрасывается с записью в лог, а не роняет ответ: остальная его часть уходит клиенту как обычно.

Досрочное завершение ответа

У обработчика часто остаётся работа после того, как ответ уже готов: дёрнуть вебхук, положить задачу в очередь, прогреть кеш. Клиенту ждать этого не нужно.

rapira_finish_request() завершает ответ прямо в этой точке. Буфер сбрасывается, ответ уходит через фронт к клиенту, а ваш обработчик продолжает работать, когда клиент уже держит ответ целиком. Контракт тот же, что у fastcgi_finish_request(), поэтому код, написанный под php-fpm, ведёт себя как и раньше:

php
<?php

header('Content-Type: text/plain');
echo "Order accepted\n";

rapira_finish_request();

// The client already has the response; this still runs.
$mailer->sendConfirmation($order);
$metrics->flush();

Сигнатура — rapira_finish_request(): bool. Объявлена она, как и всё остальное, что Rapira отдаёт в PHP, в файле crates/php_sys/rapira.stub.php: укажите на него свою IDE, чтобы получить автодополнение и подсказки типов.

Функция регистрируется на весь процесс и действует на тот запрос, который сейчас обслуживается, поэтому классический режим её тоже поддерживает: поведение одинаково и когда скрипт резидентный, и когда он запускается заново на каждый запрос. Что ещё различается между режимами, рассказывают Режимы выполнения.

Помнить стоит про две вещи:

  • Вывод после вызова никуда не уйдёт. Ответ уже закрыт, поэтому следующий за вызовом echo отбрасывается: он не встаёт в очередь на будущий сброс буфера. Всё, что должен увидеть клиент, пишите до вызова.
  • Воркер по-прежнему занят. Досрочный ответ освобождает клиента, а не процесс. Этот воркер не возьмёт следующий запрос, пока ваш обработчик не вернёт управление, так что работа, перенесённая за вызов, — это работа, которую следующий запрос всё равно ждёт; сколько воркеров есть в запасе, рассказывает Модель процессов. Вызов снижает задержку для клиента, но конкурентности не добавляет, поэтому тяжёлую работу отдавайте в очередь.