Le protocole HTTP, à la base des échanges sur le Web, a connu
plusieurs versions successives, la première était 0.9, la suivante 1.0
et la plus courante aujourd'hui est la 1.1. L'évolution entre ces
versions était progressive. Mais HTTP 2, dont la norme vient de
sortir, marque un saut plus important : s'il garde la sémantique de
HTTP (ses en-têtes, ses codes de retour, etc), il a désormais un
encodage binaire et un transport spécifique (binaire,
multiplexé, avec possibilité de push). Fini de déboguer des serveurs HTTP avec
telnet. En échange, le nouveau protocole promet
d'être plus rapide, notamment en diminuant la latence lors des échanges.
C'est un groupe de travail IETF, nommé httpbis, qui a mis au
point cette nouvelle version (voir son site Web). Ce groupe httpbis travaillait sur deux projets
parallèles : d'abord réorganiser les normes sur HTTP 1.1 pour corriger les
innombrables bogues, et mieux organiser la norme, sans changer le
protocole (projet conclus en juin 2014). Et un deuxième projet, HTTP version 2, connu aussi sous le
nom de code de SPDY (initialement
lancé par Google). Si SPDY a changé de nom, on
peut toutefois prévoir que cet acronyme apparaitra pendant encore un
certain temps, dans les API, codes source, messages d'erreur...
La section 1 de notre nouveau RFC résume les griefs qu'il y avait
contre HTTP 1.1, normalisé dans le RFC 7230 :
- Une seule requête au plus en attente sur une connexion
TCP donnée. Cela veut dire que, si on a deux
requêtes à envoyer au même serveur, et que l'une est lente et l'autre
rapide, il faudra faire deux connexions TCP (la solution la plus courante), ou bien se résigner au
risque que la lente bloque la rapide. (La section 6.3.2 du RFC 7230 permettait d'envoyer la seconde requête tout
de suite mais les réponses devaient être dans l'ordre des requêtes,
donc pas moyen pour une requête rapide de doubler une lente.)
- Un encodage des en-têtes inefficace, trop bavard et trop
redondant.
Au contraire, HTTP 2 n'utilisera toujours qu'une seule connexion TCP,
de longue durée (ce qui sera plus sympa pour le réseau). L'encodage
étant entièrement binaire, le traitement par le récepteur sera plus
rapide.
La section 2 de notre RFC résume l'essentiel de ce qu'il faut
savoir sur HTTP 2. Il garde la sémantique de HTTP 1 (donc, par
exemple, un GET
de
/cetteressourcenexistepas
qui faisait un 404
avant le fera toujours après) mais avec une représentation sur le
réseau très différente. On peut résumer en disant que HTTP 2 ne change
que le transport des messages, pas les messages ou leurs réponses.
Avec HTTP 2, l'unité de base de la communication est la
trame (frame, et, dans HTTP
2, vous pouvez oublier la définition traditionnelle qui en fait
l'équivalent du paquet, mais pour la couche
2). Chaque trame a un type et, par exemple, les échanges
HTTP traditionnels se feront avec simplement une trame
HEADERS
en requête et une
DATA
en réponse. Certains types sont spécifiques
aux nouvelles fonctions de HTTP 2, comme SETTINGS
ou PUSH_PROMISE
.
Les trames voyagent ensuite dans des ruisseaux
(streams), chaque ruisseau hébergeant un et un
seul échange requête/réponse. On crée donc un ruisseau à chaque fois
qu'un a un nouveau GET
ou
POST
à faire. Les petits ruisseaux sont ensuite
multiplexés dans une grande rivière, l'unique connexion TCP entre un
client HTTP et un serveur. Les ruisseaux ont des mécanismes de
contrôle du trafic et de prioritisation entre eux.
Les en-têtes sont comprimés, en favorisant le cas le plus courant,
de manière à s'assurer, par exemple, que la plupart des requêtes HTTP
tiennent dans un seul paquet de la taille des paquets
Ethernet.
Bon, maintenant, les détails pratiques (le RFC fait 92
pages). D'abord, l'établissement de la connexion. HTTP 2 tourne au
dessus de TCP. Comment on fait pour
commencer du HTTP 2 ? On utilise un nouveau
port, succédant au 80 de HTTP ? (Non. Les ports
sont les mêmes, 80 et 443.) On
regarde dans le DNS ou ailleurs si le serveur
sait faire du HTTP 2 ? (Non plus.) Il y a deux méthodes utilisées par
les clients HTTP 2. Tout dépend de si on fait du
TLS ou pas. Si on fait du TLS, on va utiliser
ALPN (RFC 7301), en indiquant l'identificateur
h2
(HTTP 2 sur TLS). Le serveur, recevant
l'extension ALPN avec h2
, saura ainsi qu'on fait
du HTTP 2 et on pourra tout de suite commencer l'échange de trames
HTTP 2. (h2
est désormais dans le registre IANA.) Si on ne fait pas de TLS
(identificateur h2c
pour « HTTP 2 in
clear » mais il est juste réservé pour information puisqu'on
ne peut pas utiliser ALPN si on n'utilise pas TLS), on va passer par l'en-tête HTTP
Upgrade:
(section 6.7 du RFC 7230) :
GET / HTTP/1.1
Host: www.example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: [base64url encoding of HTTP/2 SETTINGS payload]
Si le serveur est un vieux serveur qui ne gère pas HTTP 2, il ignorera
le
Upgrade:
:
HTTP/1.1 200 OK
Content-Length: 243
Content-Type: text/html
Si le serveur est d'accord pour faire de l'HTTP 2, il répondra 101
(
Switching Protocols) :
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
[Tout ce qui suit est en HTTP 2]
Dans ce dernier cas, la première trame envoyée par le serveur sera de
type
SETTINGS
. Rappelez-vous que chaque couple
requête/réponse HTTP aura lieu dans un ruisseau séparé. Ici, le
ruisseau numéro 1 aura été créé implicitement, et servira à la requête
Upgrade:
.
Une fois qu'un client aura réussi à établir une connexion avec un
serveur en HTTP 2, il sait que le serveur gère ce protocole. Il peut
s'en souvenir, pour les futures connexions (mais attention, ce n'est
pas une indication parfaite : un serveur peut abandonner HTTP 2, par
exemple).
Maintenant, c'est parti, on s'envoie des trames. À quoi
ressemblent-elles (section 4) ? Elles commencent par un en-tête
indiquant leur longueur, leur type (comme
SETTINGS
, HEADERS
,
DATA
... cf. section 6), des options (comme
ACK
qui sert aux trames de type
PING
à distinguer requête et réponse) et l'identificateur du ruisseau auquel la
trame appartient (un nombre sur 31 bits). Le format complet est en
section 4.1.
Les en-têtes HTTP sont comprimés selon la méthode normalisée dans
le RFC 7541.
Les ruisseaux (streams), maintenant. Ce sont donc
des suites ordonnées de trames, bi-directionnelles, à l'intérieur
d'une connexion HTTP 2. Une connexion peut comporter plusieurs
ruisseaux, chacun identifié par un stream ID (un
entier de quatre octets, pair si le ruisseau a été créé par le serveur et impair autrement). Les ruisseaux sont ouverts et fermés dynamiquement et leur
durée de vie n'est donc pas celle de la connexion HTTP
2. Contrairement à TCP, il n'y a pas de
« triple poignée de mains » : l'ouverture d'un ruisseau est
unilatérale et peut donc se faire très vite (rappelez-vous que chaque
échange HTTP requête/réponse nécessite un ruisseau qui lui est
propre ; pour vraiment diminuer la latence, il faut que leur création
soit rapide).
Un mécanisme de contrôle du flot s'assure que les ruisseaux se
partagent pacifiquement la connexion. C'est donc une sorte de TCP dans
le TCP, réinventé pour les besoins de HTTP 2 (section 5.2 et relire
aussi le RFC 1323). Le
récepteur indique (dans une trame WINDOWS_UPDATE
)
combien d'octets il est prêt à recevoir (64 Kio par
défaut) et
l'émetteur s'arrête dès qu'il a rempli cette fenêtre d'envoi. (Plus
exactement, s'arrête d'envoyer des trames DATA
:
les autres, les trames de contrôle, ne sont pas soumises au contrôle
du flot).
Comme si ce système des connexions dans les connexions n'était pas
assez compliqué comme cela, il y a aussi des dépendances entre
ruisseaux. Un ruisseau peut indiquer qu'il dépend d'un autre et,
dans ce cas, les ressources seront allouées d'abord au ruisseau dont
on dépend. Par exemple, le code JavaScript ne
peut en général commencer à s'exécuter que quand toute la page est
chargée, et on peut donc le demander dans un ruisseau dépendant de
celle qui sert à charger la page. On peut dépendre d'un ruisseau
dépendant, formant ainsi un arbre de dépendances.
Il peut bien sûr y avoir des erreurs dans la
communication. Certaines affectent toute la connexion, qui devra être
abandonnée, mais d'autres ne concernent qu'un seul ruisseau. Dans le
premier cas, celui qui détecte l'erreur envoie une trame
GOAWAY
(dont on ne peut pas garantir qu'elle sera
reçue, puisqu'il y a une erreur) puis coupe la connexion TCP. Dans le
second cas, si le problème ne concerne qu'un seul ruisseau, on envoie
la trame RST_STREAM
qui arrête le traitement
du ruisseau.
Notre section 5 se termine avec des règles qui indiquent comment
gérer des choses inconnues dans le dialogue. Ces règles permettent
d'étendre HTTP 2, en s'assurant que les vieilles mises en œuvre ne
pousseront pas des hurlements devant les nouveaux éléments qui
circulent. Par exemple, les trames d'un type inconnu doivent être
ignorées et mises à la poubelle directement, sans protestation.
On a déjà parlé plusieurs fois des trames, la section 6 du RFC
détaille leur définition. Ce sont aux ruisseaux ce que les paquets sont
à IP et les segments à
TCP. Les trames ont un type (un entier d'un
octet). Les types possibles sont enregistrés à
l'IANA. Les principaux types actuels sont :
DATA
(type 0), les trames les plus nombreuses,
celles qui portent les données, comme les pages
HTML,
HEADERS
(type 1), qui portent les en-têtes
HTTP, dûment comprimés selon le RFC 7541,
PRIORITY
(type 2) indique la priorité que
l'émetteur donne au ruisseau qui porte cette trame,
RST_STREAM
(type 3), dont j'ai parlé plus
haut à propos des erreurs, permet de terminer un ruisseau (filant la
métaphore, on pourrait dire que cela assèche le ruisseau ?),
SETTINGS
(type 4), permet d'envoyer des
paramètres, comme SETTINGS_HEADER_TABLE_SIZE
, la
taille de la table utilisée pour la compression des en-têtes,
SETTINGS_MAX_CONCURRENT_STREAMS
pour indiquer
combien de ruisseaux est-on prêt à gérer, etc (la liste des paramètres
est dans un registre IANA),
PUSH_PROMISE
(type 5) qui indique qu'on
va transmettre des données non sollicitées (push), du
moins si le paramètre SETTINGS_ENABLE_PUSH
est à 1,
PING
(type 6) qui permet de tester le
ruisseau (le partenaire va répondre avec une autre trame
PING
, ayant l'option ACK
à 1),
GOAWAY
(type 7) que nous avons déjà vu plus
haut, sert à mettre fin proprement (le pair est informé de ce qui va
se passer) à une connexion,
WINDOW_UPDATE
(type 8) sert à faire varier
la taille de la fenêtre (le nombre d'octets qu'on peut encore
accepter, cf. section 6.9.1),
CONTINUATION
(type 9), indique la suite
d'une trame précédente. Cela n'a de sens que pour certains types comme
HEADERS
(ils peuvent ne pas tenir dans une seule
trame) ou CONTINUATION
lui-même. Mais une trame
CONTINUATION
ne peut pas être précédée de
DATA
ou de PING
, par exemple.
Dans le cas vu plus haut d'erreur entrainant la fin d'un ruisseau
ou d'une connexion entière, il est nécessaire d'indiquer à son
partenaire en quoi consistait l'erreur en question. C'est le rôle des
codes d'erreur de la section 7. Stockés sur quatre octets (et
enregistrés dans un registre IANA), ils sont
transportés par les trames RST_STREAM
ou
GOAWAY
qui terminent, respectivement, ruisseaux et
connexions. Parmi ces codes :
NO_ERROR
(code 0), pour les cas de
terminaison normale,
PROTOCOL_ERROR
(code 1) pour ceux où le
pair a violé une des règles de HTTP 2, par exemple en envoyant une
trame CONTINUATION
qui n'était pas précédée de
HEADERS
, PUSH_PROMISE
ou
CONTINUATION
,
INTERNAL_ERROR
(code 2), un malheur est
arrivé,
ENHANCE_YOUR_CALM
(code 11), qui ravira les
amateurs de spam et de
Viagra, demande au partenaire en face de se
calmer un peu, et d'envoyer moins de requêtes.
Toute cette histoire de ruisseaux, de trames, d'en-têtes comprimés
et autres choses qui n'existaient pas en HTTP 1 est bien jolie mais
HTTP 2 n'a pas été conçu comme un remplacement de TCP, mais comme un
moyen de faire passer des dialogues
HTTP. Comment met-on les traditionnelles
requêtes/réponses HTTP sur une connexion HTTP 2 ? La section 8 répond
à cette question. D'abord, il faut se rappeler que HTTP 2 reste du
HTTP. L'essentiel des RFC « sémantiques » HTTP, à savoir les
RFC 7231, RFC 7232 et les
suivants s'applique toujours. Le RFC 7230 reste
partiellement applicable : la sémantique est la même mais son
expression change. Certains en-têtes disparaissent comme
Connection:
qui n'est plus utile en HTTP 2.
HTTP reste requête/réponse. Pour envoyer une requête, on utilise
un nouveau ruisseau (envoi d'une trame avec un numéro de ruisseau non
utilisé), sur laquelle on lira la réponse (les ruisseaux ne sont pas persistents). Dans le cas le plus
fréquent, la requête sera composée d'une trame
HEADERS
contenant les en-têtes (comme
User-Agent:
ou Host:
,
cf. RFC 7230, section 3.2) et les
« pseudo-en-têtes » comme la méthode (GET
,
POST
, etc), avec parfois des trames
DATA
(cas d'un POST
). La
réponse comprendra une trame HEADERS
avec les
en-têtes (comme Content-Length:
) et les
pseudo-en-têtes comme le code de retour HTTP (200, 403, 500, etc)
suivie de plusieurs trames DATA
contenant les
données (HTML, CSS,
images, etc). Des variantes sont possibles (par exemple, les trames
HEADERS
peuvent être suivies de trames
CONTINUATION
). Les en-têtes ne sont pas
transportés sous forme texte (ce qui était le cas en HTTP 1, où on
pouvait utiliser telnet comme client HTTP) mais
encodés selon le RFC 7541. À noter que cet
encodage implique une mise du nom de l'en-tête en minuscules.
J'ai parlé plus haut des pseudo-en-têtes : c'est le mécanisme HTTP
2 pour traiter des informations qui ne sont pas des en-têtes HTTP
1. Ces informations sont mises dans les HEADERS
HTTP 2, précédés d'un deux-points. C'est le cas
de la méthode (RFC 7231, section 4), donc
GET
sera encodé :method
get
. L'URL sera éclaté dans les pseudo-en-têtes
:scheme
, :path
, etc. Idem
pour la réponse HTTP, le fameux code à trois lettres est désormais un
pseudo-en-tête, :status
.
Voici un exemple (mais vous ne le verrez pas ainsi si vous espionnez
le réseau, en raison de la compression du RFC 7541) :
### HTTP 1, pas de corps dans la requête ###
GET /resource HTTP/1.1
Host: example.org
Accept: image/jpeg
### HTTP 2 (une trame HEADERS)
:method = GET
:scheme = https
:path = /resource
host = example.org
accept = image/jpeg
Puis une réponse qui n'a pas de corps :
### HTTP 1 ###
HTTP/1.1 304 Not Modified
ETag: "xyzzy"
Expires: Thu, 23 Jan ...
### HTTP 2, une trame HEADERS ###
:status = 304
etag = "xyzzy"
expires = Thu, 23 Jan ...
Une réponse plus traditionnelle, qui inclut un corps :
### HTTP 1 ###
HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 123
{binary data}
### HTTP 2 ###
# trame HEADERS
:status = 200
content-type = image/jpeg
content-length = 123
# trame DATA
{binary data}
Plus compliqué, un cas où les en-têtes de la requête ont été mis dans
deux trames, et où il y avait un corps dans la requête :
### HTTP 1 ###
POST /resource HTTP/1.1
Host: example.org
Content-Type: image/jpeg
Content-Length: 123
{binary data}
### HTTP 2 ###
# trame HEADERS
:method = POST
:path = /resource
:scheme = https
# trame CONTINUATION
content-type = image/jpeg
host = example.org
content-length = 123
# trame DATA
{binary data}
Nouveauté de HTTP 2, la possibilité pour le serveur de pousser
(push, section 8.2 de notre RFC) du contenu non sollicité vers le client
(sauf si cette possibilité a été coupée par le paramètre
SETTINGS_ENABLE_PUSH
). Pour cela, le serveur (et
lui seul)
envoie une trame de type PUSH_PROMISE
au
client, en utilisant le ruisseau où le client avait fait une demande
originale (donc, la sémantique de PUSH_PROMISE
est « je te promets que lorsque le moment sera venu, je répondrai plus
longuement à ta
question »). Cette trame contient une requête HTTP. Plus tard, lorsque le
temps sera venu, le serveur tiendra sa promesse en envoyant la
« réponse » de cette « requête » sur le ruisseau qu'il avait indiqué
dans le PUSH_PROMISE
.
Et enfin, à propos des méthodes HTTP 1 et de leur équivalent en
HTTP 2, est-ce que CONNECT
(RFC 7231, section 4.3.6) fonctionne toujours ?
Oui, on peut l'utiliser pour un tunnel sur un
ruisseau. (Un tunnel sur un ruisseau... Beau défi pour le
génie civil.)
La section 9 de notre RFC rassemble quelques point divers. Elle
rappelle que, contrairement à HTTP 1, toutes les connexions sont
persistentes et que le client n'est pas censé les fermer avant d'être
certain qu'il n'en a plus besoin. Tout doit passer à travers une
connexion vers le serveur et les clients ne doivent plus utiliser le
truc d'ouvrir plusieurs connexions HTTP avec le serveur. De même, le
serveur laisse les connexions ouvertes le plus longtemps possible,
mais a le droit de les fermer s'il doit économiser des ressources.
À noter qu'on peut utiliser une connexion prévue pour un autre nom,
du moment que cela arrive au même serveur (même adresse IP). Le
pseudo-en-tête :authority
sert à départager les
requêtes allant à chacun des serveurs. Mais attention si la session
utilise TLS ! L'utilisation d'une connexion
avec un autre :authority
(host
+ port) n'est possible que si le certificat serveur qui a été utilisé
est valable pour tous (par le biais des
subjectAltName
, ou bien d'un joker).
À propos de TLS (certainement un des points
les plus chaudement disputés à l'IETF dans les
discussions sur HTTP 2), la section 9.2 prévoit quelques règles qui
n'existaient pas en HTTP 1 (et dont la violation peut entrainer la
coupure de la connexion avec l'erreur INADEQUATE_SECURITY
) :
- TLS 1.2 (RFC 5246), minimum,
- Gestion de SNI (RFC 6066)
obligatoire,
- Compression coupée (RFC 3749), comme indiqué dans le RFC 7525 (permettre la compression de données qui mêlent
informations d'authentification comme les
cookies, et données contrôlées par
l'attaquant, permet certaines attaques comme BREACH) ce qui n'est pas grave puisque HTTP a de meilleures
capacités de compression (voir aussi la section
10.6),
- Renégociation coupée, ce qui
empêche de faire une renégociation en réponse à une certaine requête
(pas de solution dans ce cas) et peut conduire à couper une connexion
si le mécanisme de chiffrement sous-jacent ne permet pas d'encoder
plus de N octets sans commencer à faire fuiter de l'information.
- Très sérieuse limitation du nombre d'algorithmes de
chiffrement acceptés (voir l'annexe A pour une
liste complète), en éliminant les algorithmes trop faibles
cryptographiquement (comme les algorithmes « d'exportation » utilisés
dans la faille FREAK). Peu d'algorithmes restent utilisables après avoir
retiré cette liste !
TLS avait été au cœur d'un vigoureux débat : faut-il essayer
systématiquement TLS, même sans authentification, même si
l'URL est de plan http:
et
pas https:
? Ce « chiffrement opportuniste » avait
été proposé à l'IETF, pour diminuer l'efficacité de la surveillance
massive (RFC 7258). C'était le ticket #314 du
groupe de travail, et ce fut un des plus disputés. Quelques articles
sur ce sujet du « tentons TLS à tout hasard » : http://bitsup.blogspot.fr/2013/08/ssl-everywhere-for-http2-new-hope.html
,
http://it.slashdot.org/story/13/11/13/1938207/http-20-may-be-ssl-only
,
http://www.pcworld.com/article/2061189/next-gen-http-2-0-protocol-will-require-https-encryption-most-of-the-time-.html
,
https://www.tbray.org/ongoing/When/201x/2013/11/05/IETF-88-HTTP-Security
,
http://www.mnot.net/blog/2014/01/04/strengthening_http_a_personal_view
ou http://lists.w3.org/Archives/Public/ietf-http-wg/2013OctDec/0625.html
. Il
faut préciser que l'IETF n'a pas de pouvoirs de police : même si
le RFC sur HTTP 2 avait écrit en gros « le chiffrement est obligatoire »,
rien ne garantit que cela aurait été effectif.
Finalement, la
décision a été de ne pas imposer ce passage en TLS, mais la question
reste en discussion à l'IETF pour le plus grand déplaisir de ceux qui voudraient espionner le
trafic plus facilement.
Puisqu'on parle de sécurité, la section 10 traite un certain nombre
de problèmes de sécurité de HTTP 2. Parmi ceux qui sont spécifiques à
HTTP 2, on note que ce protocole demande plus de ressources que HTTP
1, ne serait-ce que parce qu'il faut maintenir un état pour la
compression. Il y a donc potentiellement un risque
d'attaque par déni de service. Une mise en
œuvre prudente veillera donc à limiter les ressources allouées à
chaque connexion.
Enfin, il y a la question de la vie privée,
un sujet chaud dans le monde HTTP depuis longtemps. Les options
spécifiques à HTTP 2 (changement de paramètres, gestion du contrôle de
flot, traitement des innombrables variantes du protocole) peuvent
permettre d'identifier une machine donnée par son comportement. HTTP 2
facilite donc le
fingerprinting.
En outre,
comme une seule connexion TCP est utilisée pour toute une visite sur
un site donné, cela peut rendre explicite une information comme « le
temps passé sur un site », information qui était implicite en HTTP 1,
et qui devait être reconstruite.
Question mises en œuvre de la nouvelle version de HTTP, un bon
point de départ est la
liste du groupe de travail. nginx n'a
pas HTTP 2 mais y
travaille. Pour le cas particulier des
navigateurs Web, il y a un joli
tableau.
Désolé de ne pas vous montrer un joli pcap
de HTTP 2 mais la plupart des sites accessibles en HTTP 2 (et parfois
des
clients, « Firefox will only be implementing HTTP/2 over TLS ») imposent TLS, ce qui rend un peu plus compliquée
l'analyse. Je n'ai pas encore trouvé de pcap HTTP 2 sur pcapr non plus, mais il y en a à la fin de la
page http://daniel.haxx.se/http2/
.
Quelques articles pertinents :