Limitation du débit et adresse du client
rate_limit dans un contrôleur plafonne la fréquence à laquelle un client peut
appeler certaines actions, compté avant l’exécution de l’action :
class SessionsController < ApplicationController
# une 303 vers le formulaire, avec flash[:alert], une fois la limite atteinte
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
Chaque déclaration compte au plus to requêtes toutes les within secondes
pour chaque adresse de client et chaque contrôleur (une fenêtre fixe qui
s’ouvre avec la première requête), puis répond 429 avec un en-tête
Retry-After, ou redirige quand redirect_to: est fourni. within: est en
secondes, only:/except: prennent des Array, et il n’y a pas de bloc
with:. Les compteurs vivent dans le processus, dans une table d’au plus
10 000 adresses (quand elle est pleine, les fenêtres expirées puis les plus
anciennes font de la place) : ils sont donc perdus au redémarrage, et chaque
processus compte de son côté. Chaque requête vers une action limitée prend un
verrou : nommez les actions avec only:. Dans un test,
Cybertrain::RateLimit.reset_all! oublie tous les compteurs et
Cybertrain::RateLimit.current_time = n fige l’horloge (-1 la rétablit).
L’adresse du client derrière un proxy
L’adresse comptée est request.client_ip (by: :remote_addr compte plutôt le
pair de la connexion). C’est request.remote_addr, sauf si
config.trusted_proxy est activé (CYBERTRAIN_TRUSTED_PROXY=1) et que la
connexion vient du loopback, c’est-à-dire d’un reverse proxy sur le même hôte
(nginx, Caddy, kamal-proxy) ; c’est alors la dernière adresse de
X-Forwarded-For, celle que le proxy a ajoutée. Les entrées précédentes sont
ce que le client a envoyé et ne sont jamais utilisées, et une dernière entrée
vide, plus longue que 45 octets ou qui n’est pas de l’ASCII imprimable donne
remote_addr. Sans ce réglage, tous les visiteurs derrière un tel proxy
partagent son adresse, et dix mauvais mots de passe bloquent tout le
monde ; avec lui, un client qui se connecte directement depuis ailleurs ne
peut toujours pas choisir sa propre adresse. request.loopback? indique si la
connexion vient de cette machine (127.0.0.0/8, ::1, ::ffff:127.x.x.x), pour
les pages que seul un opérateur sur le serveur doit atteindre ; derrière un
proxy sur le même hôte, toute requête vient du loopback : vérifiez donc
aussi client_ip dans ce cas. Conception : docs/design.md
D26.
Un proxy qui atteint l’application par un réseau, et non par le loopback, est
plutôt listé par adresse : config.trusted_proxies, issu de
CYBERTRAIN_TRUSTED_PROXIES, prend des adresses IPv4 ou IPv6 ou des CIDR
séparés par des virgules (CYBERTRAIN_TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8,
ou un seul 172.18.0.2). Une connexion venant de l’un d’eux reçoit le même
traitement qu’une connexion loopback ci-dessus : la dernière adresse de
X-Forwarded-For, validée de la même façon. Une entrée IPv4 correspond aussi à
la forme IPv4-mappée de ses adresses (::ffff:172.18.0.2), et le %zone d’un
pair est ignoré. Une entrée qui n’est ni une adresse ni un CIDR, ou dont le
préfixe dépasse /32 (IPv4) ou /128 (IPv6), arrête le serveur au démarrage.
CYBERTRAIN_TRUSTED_PROXY=1 est le raccourci de 127.0.0.0/8,::1, et les deux
s’additionnent.
C’est le cas de Kamal : kamal-proxy tourne dans son propre
conteneur Docker et se connecte depuis son adresse sur le réseau Docker, donc le
config/deploy.yml qu’écrit cybertrain new définit
CYBERTRAIN_TRUSTED_PROXIES: 172.16.0.0/12 (la plage par défaut de
Docker ; changez-la si vos réseaux en utilisent une autre,
docker network inspect kamal). Laissez proxy.forward_headers de Kamal non
défini : avec ssl: true, kamal-proxy définit lui-même X-Forwarded-For
au lieu de transmettre celui du client. Ne listez que des réseaux depuis
lesquels les clients ne peuvent pas se connecter : toute personne à
l’intérieur d’un réseau listé peut choisir l’adresse que compte une limite de
débit.
En-têtes paramétrés
Les clients d’API envoient souvent des en-têtes Scheme k1=v1, k2="v2"
(Authorization:
Digest ..., un X-Example-Auth propre à un fournisseur) ou des listes
k1=v1;k2=v2, k3 (Forwarded, Prefer). request.header_params(name) en
analyse un en un Hash qui associe des noms en minuscules à des valeurs, pour
qu’un contrôleur d’API n’ait pas à le découper à la main :
# 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
, et ; séparent tous deux les paramètres, et la première occurrence d’un
nom l’emporte ; une valeur entre guillemets est débarrassée de ses
guillemets (\" et \\ défaits), une valeur sans guillemets est débarrassée
des espaces qui l’entourent, et un nom sans = est associé à "". Un mot de
schéma initial (Digest, Bearer) n’est pas un paramètre :
request.header_scheme(name) le renvoie ("" s’il n’y en a pas). Un en-tête
absent ou plus long que 8 Kio donne {}. Les valeurs sont les octets tels
qu’envoyés, jamais décodés depuis le pourcent et pas forcément de l’UTF-8
valide. Un identifiant Bearer <token> ou Basic <base64> n’est pas un
paramètre : vérifiez le schéma, puis lisez l’identifiant dans
request.header("authorization"). Conception :
docs/design.md D31.
Chaînes de requête et corps
Query.parse, Query.value_of, Query.decode et request.form_value / query_value lèvent Cybertrain::QueryMalformed (une StandardError sous Cybertrain::QueryInvalid, pas une ArgumentError), à laquelle il est répondu 400, sur :
- un échappement pourcent mal formé, sur les deux runtimes ;
- une séquence d’octets UTF-8 invalide, brute (quel que soit le tag de la String) ou encodée en pourcent (Rails répond de même 400 pour un encodage de paramètre invalide) ;
- un octet brut que seul un échappement compléterait (
a=\xC3%A9).
Cookies.parse garde plutôt une telle valeur brute. Un chemin avec une séquence invalide (brute, ou décodée depuis le pourcent dans un segment) donne aussi une 400 (request.path_segments) ; un échappement mal formé dans un chemin reste littéral, une 404 faute de correspondance. Les paramètres de requête s’imbriquent sur 32 niveaux et 4096 paires au plus, et donnent une 400 au-delà. Note de mise à niveau : le code d’application qui faisait rescue ArgumentError autour de ces appels doit plutôt faire rescue Cybertrain::QueryInvalid.
La requête ne garde pas d’arbre de paramètres à elle (le Router construit params, le middleware lit une seule clé) ; une application qui veut l’arbre entier l’analyse elle-même, Query.parse(request.utf8_query_string), et possède le résultat.
Rack laisse un corps de requête gzip tel quel. Ici, un corps envoyé avec Content-Encoding: gzip (un SDK ou un client d’API au-delà de quelques kilo-octets) est request.decoded_body, décompressé une fois via la zlib du système sous un plafond de taille de sortie : 10 Mio par défaut, et un point d’accès qui attend davantage passe le sien, request.decoded_body(limit). Un corps corrompu ou un encodage autre que gzip/identity donne Cybertrain::BodyUndecodable, un corps qui dépasserait la limite une fois décompressé Cybertrain::BodyTooLarge, deux QueryInvalid auxquelles il est répondu 400. request.body reste les octets tels qu’envoyés.