Ограничение частоты запросов и адрес клиента
rate_limit в контроллере ограничивает, как часто один клиент может вызывать
некоторые действия; подсчёт ведётся до выполнения действия:
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-контроллер не разбивал его вручную:
# 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 остаётся байтами в том виде, в каком они отправлены.