CyberTrainРуководстваЗапросы

Запросы

Что контроллер получает из запроса и что фреймворк проверяет, прежде чем он запустится: ограничение частоты запросов и адрес клиента за прокси, параметризованные заголовки, а также то, как разбираются и ограничиваются строки запроса и тела запросов.

Ограничение частоты запросов и адрес клиента

rate_limit в контроллере ограничивает, как часто один клиент может вызывать некоторые действия; подсчёт ведётся до выполнения действия:

ruby
class SessionsController < ApplicationController
  # когда лимит исчерпан — 303 обратно к форме, с flash[:alert]
  rate_limit to: 10, within: 3 * 60, only: [:create],
             redirect_to: "/session/new", alert: "Try again later."
end

class ApiKeysController < ApplicationController
  rate_limit to: 100, within: 60, only: [:create, :update]   # 429 Too Many Requests + Retry-After
end

Каждое объявление засчитывает не более to запросов за within секунд для каждого адреса клиента и контроллера (фиксированное окно, которое открывается с первым запросом), а затем отвечает 429 с заголовком Retry-After или делает редирект, если задан redirect_to:. within: задаётся в секундах, only:/except: принимают массивы, блока with: нет. Счётчики живут в процессе, в таблице не более чем на 10 000 адресов (когда она заполнена, место освобождают истёкшие окна, а затем самые старые), поэтому при перезапуске они теряются, и каждый процесс считает сам по себе. Каждый запрос к ограниченному действию берёт одну блокировку: называйте действия через only:. В тесте Cybertrain::RateLimit.reset_all! забывает все счётчики, а Cybertrain::RateLimit.current_time = n фиксирует часы (-1 их восстанавливает).

Адрес клиента за прокси

Засчитываемый адрес — request.client_ip (by: :remote_addr вместо этого считает собеседника соединения). Это request.remote_addr, если только не включён config.trusted_proxy (CYBERTRAIN_TRUSTED_PROXY=1) и соединение приходит с loopback, то есть от обратного прокси на том же хосте (nginx, Caddy, kamal-proxy); тогда это последний адрес в X-Forwarded-For — тот, который добавил прокси. Более ранние записи — это то, что прислал клиент, и они никогда не используются, а пустая последняя запись, запись длиннее 45 байт или не из печатных символов ASCII даёт remote_addr. Без этой настройки все посетители за таким прокси делят его адрес, и десять неверных паролей блокируют всех; с ней клиент, подключающийся напрямую откуда-то ещё, всё равно не может выбрать собственный адрес. request.loopback? сообщает, приходит ли соединение с этой машины (127.0.0.0/8, ::1, ::ffff:127.x.x.x), — для страниц, к которым должен иметь доступ только оператор на сервере; за прокси на том же хосте каждый запрос — loopback, поэтому там проверяйте также client_ip. Проектное решение: docs/design.md D26.

Прокси, который обращается к приложению по сети, а не через loopback, указывается по адресу: config.trusted_proxies из CYBERTRAIN_TRUSTED_PROXIES принимает разделённые запятыми адреса IPv4 или IPv6 либо CIDR (CYBERTRAIN_TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8 или один 172.18.0.2). Соединение изнутри одного из них обрабатывается так же, как соединение с loopback выше: последний адрес X-Forwarded-For, проверенный тем же способом. Запись IPv4 также совпадает с IPv4-mapped формой своих адресов (::ffff:172.18.0.2), а %zone собеседника игнорируется. Запись, которая не является адресом или CIDR либо имеет префикс больше /32 (IPv4) или /128 (IPv6), останавливает сервер при запуске. CYBERTRAIN_TRUSTED_PROXY=1 — сокращение для 127.0.0.0/8,::1, и обе настройки складываются.

Это случай Kamal: kamal-proxy работает в собственном контейнере Docker и подключается со своего адреса в сети Docker, поэтому config/deploy.yml, который пишет cybertrain new, задаёт CYBERTRAIN_TRUSTED_PROXIES: 172.16.0.0/12 (диапазон Docker по умолчанию; измените его, если ваши сети используют другой, docker network inspect kamal). Оставьте proxy.forward_headers Kamal незаданным: с ssl: true kamal-proxy сам задаёт X-Forwarded-For, а не передаёт заголовок клиента. Указывайте только сети, из которых не могут подключаться клиенты: любой внутри указанной сети может выбрать адрес, который засчитывает ограничение частоты.

Параметризованные заголовки

API-клиенты часто отправляют заголовки вида Scheme k1=v1, k2="v2" (Authorization: Digest ..., вендорский X-Example-Auth) или списки k1=v1;k2=v2, k3 (Forwarded, Prefer). request.header_params(name) разбирает такой заголовок в Hash, отображающий имена в нижнем регистре на значения, чтобы API-контроллер не разбивал его вручную:

ruby
# Authorization: Digest username="Mufasa", realm="api@example.com", nc=00000001, qop=auth
auth = request.header_params("authorization")
auth["username"]                         # => "Mufasa"
request.header_scheme("authorization")   # => "Digest"

# Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43
request.header_params("forwarded")["proto"]   # => "https"

# Prefer: respond-async, wait=5
request.header_params("prefer").key?("respond-async")   # => true

И ,, и ; разделяют параметры, и побеждает первое вхождение имени; значение в кавычках освобождается от них (\" и \\ раскрываются), у значения без кавычек обрезаются окружающие пробелы, а имя без = отображается на "". Начальное слово схемы (Digest, Bearer) параметром не является: его возвращает request.header_scheme(name) ("", если схемы нет). Заголовок, который отсутствует или длиннее 8 КиБ, даёт {}. Значения — это байты в том виде, в каком они отправлены, никогда не percent-декодированные и, возможно, не являющиеся допустимым UTF-8. Учётные данные Bearer <token> или Basic <base64> параметром не являются: проверьте схему, а затем прочитайте учётные данные из request.header("authorization"). Проектное решение: docs/design.md D31.

Строки запроса и тела

Query.parse, Query.value_of, Query.decode и request.form_value / query_value возбуждают Cybertrain::QueryMalformed (StandardError в Cybertrain::QueryInvalid, а не ArgumentError), на что отвечается 400, при:

  • некорректной percent-последовательности, в обеих средах выполнения;
  • недопустимой последовательности байтов UTF-8 — сырой (каким бы ни был тег String) или percent-кодированной (Rails тоже отвечает 400 на недопустимую кодировку параметра);
  • сыром байте, который дополнила бы только escape-последовательность (a=\xC3%A9).

Cookies.parse вместо этого сохраняет такое значение сырым. Путь с недопустимой последовательностью (сырой или percent-декодированной в сегменте) — тоже 400 (request.path_segments); некорректная escape-последовательность в пути остаётся буквальной — 404 из-за несовпадения. Параметры запроса вкладываются не более чем на 32 уровня и 4096 пар, сверх этого — 400. Примечание к обновлению: код приложения, который перехватывал ArgumentError вокруг этих вызовов, должен вместо этого перехватывать Cybertrain::QueryInvalid.

Запрос не хранит собственного дерева параметров (Router строит params, middleware читает один ключ); приложение, которому нужно всё дерево, разбирает его само, Query.parse(request.utf8_query_string), и владеет результатом.

Rack оставляет тело запроса в gzip как есть. Здесь тело, отправленное с Content-Encoding: gzip (SDK или API-клиент, когда тело больше нескольких килобайт), — это request.decoded_body, распакованное один раз через системную zlib с ограничением размера результата: по умолчанию 10 МиБ, а эндпоинт, ожидающий больше, передаёт своё, request.decoded_body(limit). Повреждённое тело или кодировка, отличная от gzip/identity, — это Cybertrain::BodyUndecodable, тело, которое распаковалось бы сверх лимита, — Cybertrain::BodyTooLarge; оба — QueryInvalid, на которые отвечается 400. request.body остаётся байтами в том виде, в каком они отправлены.