13min.

L’histoire d’Unicode et son adoption sur le Web

Vous avez tous entendu parler d’Unicode, mais savez-vous comment implémenter correctement son support dans vos applications ? Et savez-vous vraiment comment il fonctionne ? Cet article reprend le contenu de ma conférence « Pourquoi strlen(« 🍕​ ») != 1 ? », donc si vous étiez au Forum PHP 2016, vous pouvez fermer cet onglet et reprendre une activité normale.

Dans le cas contraire, laissez-moi vous conter l’histoire d’Unicode et comment s’en servir dans un projet PHP.

Section intitulée l-encodageL’encodage

Nos ordinateurs parlent en bits : 1, 0. Nous communiquons avec du texte. Il nous a donc fallu une technique pour traduire du texte en bits et inversement. De plus, notre ordinateur doit communiquer avec d’autres ordinateurs, des imprimantes, claviers…

​Cela a donné lieux à l’invention de l’encodage (par 🇫🇷 l’Ingénieur Mimault avec son Code Baudot). Il s’agit d’une liste de règles pour transformer une donnée dans les deux sens ; dans le cas du texte, cela revient à dire que « 01100001 » vaut « a ».

Au début de l’informatique moderne, c’est le chaos : chaque constructeur encodait à sa façon (une bonne technique pour garder les clients ?). Un document quittait rarement le système dans lequel il avait été écrit et l’interopérabilité n’existait pas. Mais ça ne pouvait plus durer, en 1963, le « American National Standards Institute », ANSI pour les intimes, lança un comité pour produire le standard que nous connaissons aujourd’hui sous le nom d’ASCII.

Ce fut long et pénible : quels caractères inclure, dans quel ordre, quelle compatibilité avec les non-standards existants… Mais c’est ici qu’ont été posées les bases du partage de texte en informatique.

Section intitulée american-standard-code-for-information-interchangeAmerican Standard Code for Information Interchange

Publié en 1963 (donc), cet encodage utilise 7 bits par caractère, ce qui offre 127 possibilités.

ASCII

Comme vous pouvez le constater sur cette table, nous retrouvons des caractères de contrôle (dont la majorité ne servent plus à rien), de la ponctuation, des chiffres et l’alphabet anglais en majuscules et minuscules.

Je pense que mon préféré est ␙ (End of medium) : il permettait de prévenir quand il n’y avait plus de papier ou de bande magnétique 📼.

​Bref, c’était super pour les Américains 🇺🇸, mais vraiment juste pour eux : en effet, on ne trouve aucune lettre accentuée dans ce standard. En 1972, les premiers CPU 8 bits arrivent (merci IBM), il y a donc une opportunité à saisir : avec 8 bits, il est possible d’encoder 255 caractères !

Le monde saisit sa chance de pouvoir encoder é, ß, ü, ä, ö, å, tout en étant compatible ASCII (et donc avec la majorité des systèmes informatiques de l’époque).

Pour simplifier, c’est à partir de ce moment là que chaque pays a eu sa variante :

  • ISO 8859–2 Western and Central Europe
  • ISO 8859–3 Turkish, Maltese plus Esperanto
  • ISO 8859–4 Lithuania, Estonia, Latvia and Lapp
  • ISO 8859–5 Cyrillic alphabet
  • ISO 8859–6 Arabic
  • ISO 8859–7 Greek
  • ISO 8859–8 Hebrew
  • ISO 8859–9 Western Europe with amended Turkish
  • ISO 8859–11 Thai
  • ISO 8859–14 Celtic languages
  • ISO 8859–15 Added the Euro sign from ISO 8859–1…

Si vous vouliez écrire en Grec, vous utilisiez donc ISO 8859–7. Pour le Français, nous utilisions ISO 8859–1. Et pour écrire du Français dans un document en Grec… c’était compliqué (comprendre « pas possible » !).

Section intitulée l-arrivee-d-internet-et-du-mojibakeL’arrivée d’internet et du Mojibake

L’un des gros problèmes de l’encodage, c’est que le résultat final reste une suite de 1 et de 0 ne portant aucune autre information. Il est donc impossible de savoir si un document texte a été écrit en ISO 8859–7 ou en ISO 8859–11. Internet a grandement facilité l’échange de documents à travers le monde et le problème est devenu très… visible.

Ce fut l’âge d’or du MOJIBAKE ! C’est le nom que porte le bug d’affichage lié à l’utilisation du mauvais encodage lors de la lecture d’un caractère, et C’EST GéNIAL.

Section intitulée unicode-le-sauveurUnicode le sauveur

Publié en 1991 (bien avant la Playstation, les Pokémons et le HTML), ce nouveau standard est né avec trois principes clés :

  • être universel : supporter toutes les langues ;
  • être uniforme : des représentations binaires de taille fixe pour être facile à décoder ;
  • être unique : une seule interprétation possible par code.

Unicode 1.0 avait 65536 code points disponibles et utilisait 16 bits par caractère (UCS). Ce fut un tel soulagement que tout le monde sauta sur l’occasion : JavaScript, C, Java…

Mais les concepteurs de cette première version avaient malheureusement vu trop petit ! Dès 1993, plus de la moitié des code points disponibles étaient assignés, et il manquait encore de nombreuses langues.

Section intitulée unicode-2–0-et-utfUnicode 2.0 et UTF

En 1996, Unicode change de fonctionnement et introduit UTF. Désormais, l’encodage est séparé de la liste des code points :

  • Unicode désigne le catalogue de code points, qui dispose maintenant de 1,114,112 emplacements ;
  • UTF (Universal Character Set Transformation Format) désigne l’encodage.

Les différents UTF se différencient par leur manière de stocker les code points et leur compatibilité avec ASCII.

UTF

UTF-8 est le plus utilisé car il est compatible avec ASCII : si vous écrivez la lettre « A », c’est la même suite de 1 et de 0 qui sera stockée quel que soit l’encodage. Dès que vous sortez d’ASCII, le nombre d’octets augmente jusqu’à un maximum de 4.

UTF-16, lui, a la particularité d’être compatible UCS, et donc Unicode 1.0. C’est un avantage majeur, notamment pour Javascript qui a pu passer à UTF-16 sans casser tous les chaînes littérale. Son inconvénient est de ne pas être compatible ASCII, et que les caractères en dehors de la BMP (Basic Multilingual Plane, les 65k caractères les plus courants) occupent deux paires UTF-16 (donc 4 octets). C’est l’encodage qui a été choisi pour PHP 6 à l’époque aussi !

Et enfin, UTF-32 est l’encodage de taille fixe qui permet de tout stocker sur 4 octets sans se poser de question (au prix d’un gaspillage d’espace disque, de bande passante, de RAM…). Il est le seul à respecter le second adage d’Unicode : être uniforme.

Ne vous posez pas trop de questions, UTF-8 est l’encodage qu’il vous faut ! Aujourd’hui, il est utilisé par 87% du Web (d’après W3Techs.com) et vous l’utilisez déjà certainement sans trop le savoir.

Aujourd’hui, Unicode est tellement grand qu’il dispose aussi d’espaces à usage privé, permettant par exemple à des fans d’encoder le Klingon ! Il supporte officiellement 135 écritures, 267k code points, des emoji 🍻 et il est recommandé par le W3C depuis HTML 4.

Ce passage par Unicode 1.0 en 16 bits nous laisse un héritage tout à fait singulier lorsque nous essayons de compter la longueur d’un texte en Javascript.

Section intitulée heritage-d-unicode-1–0-et-la-longueur-d-un-texteHéritage d’Unicode 1.0 et la longueur d’un texte

Nous savons que du texte, c’est une suite d’octets.

En fonction de l’encodage, ces octets ont un sens différent (voire pas de sens du tout) :

  • 11001001 01110100 11101001 donne « Été » en ISO-8859–1 ;
  • 11001001 01110100 11101001 donne « ةtى » en ISO-8859–6.

Certains caractères peuvent aussi être composés :

  • la lettre « é » avec la touche du clavier : 11000011 10101001 ;
  • la lettre « é » composée avec « e » et « ◌́ » : 01100101 11001100 10000001.

Il faut donc décider quoi compter : code point ? octet ? graphème ?

Prenons pour exemple « Œ » : l’e dans l’o. Il s’agit du code point 'LATIN CAPITAL LIGATURE OE’ (U+0152) et en UTF-8 il occupe deux octets : 0xC5 et 0×92.

Pour information, il est présent dans ISO-8859–15 mais pas dans ISO-8859–1 !

// PHP :
echo strlen("Œ"); // 2, nombre d'octets bête et méchant
echo mb_strlen("Œ"); // 1

// JavaScript :
'Œ'.length; // 1

// Python :
>>> len('Œ') // 2
>>> len(u'Œ') // 1 avec le flag Unicode

Comme vous pouvez le voir, en PHP, nous avons 2 avec strlen mais bien 1 avec mb_strlen. JavaScript nous donne la bonne longueur et Python se comporte comme PHP.

Maintenant si nous testons avec 🍕 à la place de Œ :

// PHP :
echo strlen("🍕"); // 4, nombre d'octets bête et méchant
echo mb_strlen("🍕"); // 1

// JavaScript :
'🍕'.length; // 2

// Python :
>>> len('🍕') // 4
>>> len(u'🍕') // 1 avec le flag Unicode

Le comportement est cohérent pour PHP et Python, par contre, JavaScript nous sort 2 au lieu de 1 !

Comme vu précédemment, en JavaScript, si vous sortez de la BMP, UTF-16 nécessite deux paires d’octets, et l’API est restée dans l’état où length compte simplement des groupes de 16 bits. Ma part de pizza est en fait composée de deux paires d’octets (compatible UTF-16 uniquement), et seul votre navigateur est capable de faire le rendu et de savoir qu’il ne s’agit que d’un seul caractère, pas le moteur JavaScript. D’où le chiffre 2 remonté par length.

En PHP, tout est simple : Unicode n’est pas supporté, mais le langage est totalement compatible, il ne va jamais faire d’opérations étranges ou incohérentes. strlen compte en octets, toutes les manipulations via l’API de base en font de même, et les comparaisons sont binaires.

Pour manipuler des chaînes de caractères, il est plutôt recommandé d’utiliser les fonctions Multibyte :

echo "🍕"[0]; // �

echo mb_substr("🍕", 0, 1, 'UTF-8') // 🍕

Ces fonctions mappent les fonctions de base avec un préfixe mb_ :

  • mb_check_encoding
  • mb_convert_encoding
  • mb_convert_case
  • mb_strlen
  • mb_strtolower…

Au passage, vous noterez que la fonction utf8_encode ne sert à rien et a été très mal nommée : elle ne fait que convertir de l’ISO-8859–1 vers l’UTF-8…

var_dump('€'); // string(3) "€"
var_dump(utf8_encode('€')); // string(6) "€", un Mojibake
var_dump(utf8_decode('€')); // string(1) "?", octet invalide

Oui, tout est cassé, le signe € est arrivé plus tard, dans ISO-8859–15 ! N’utilisez jamais ces deux fonctions !

Section intitulée faire-une-application-quot-unicode-ready-quotFaire une application « Unicode Ready »

La première chose à faire consiste bien sûr à utiliser UTF-8 ! Vos fichiers PHP doivent être encodés avec un encodage compatible ASCII pour que le parseur trouve <?php.

Vous pourriez aussi utiliser --enable-zend-multibyte à la compilation de PHP et declare(encoding='UTF-16LE'); mais c’est beaucoup de boulot juste pour le luxe de coder en non ASCII-compatible – après quelques recherches, il semblerait que ce flag soit utilisé au Japon.

L’autre option super importante de PHP, c’est default_charset ! Depuis PHP 5.6, elle est configurée sur UTF-8 par défaut : tous vos scripts retournent donc un header Content-Type UTF-8 par défaut si vous n’en spécifiez pas.

Section intitulée l-echange-de-donneesL’échange de données

Lors d’une requête à une page Web, du texte est transmis entre plusieurs systèmes :

  • de la base de données vers votre moteur PHP ;
  • de votre serveur HTTP vers votre navigateur.

À chaque échange de données, il y a forcément un encodage et il faut s’assurer que nous utilisons le bon. C’est déjà ce que nous faisons avec le header Content-Type : nous précisons à notre navigateur que le contenu est transmis en UTF-8 (ou autre) :

Content-Type: text/html; charset=utf-8

Il existe aussi le tag HTML <meta charset="utf-8" /> mais ce n’est pas très malin, le navigateur doit lire le HTML pour savoir comment lire le HTML…

Avec la base de données, ça se corse : saviez-vous que l’encodage par défaut d’une connexion MySQL dépend de beaucoup de critères et qu’il est en général… latin1 !?

Même si votre table est créée avec un charset=utf8, il est possible que PHP échange en latin1 avec MySQL.

// Aucun encodage, risque que latin1 soit utilisé
new PDO('mysql:dbname=foo;host=localhost', 'root', '');

// Encodage précisé dans la chaîne de connexion
new PDO('mysql:dbname=foo;host=localhost;charset=utf8','root', '');

// ou juste après la connexion :
'SET NAMES utf8;'

Il est possible que votre site fonctionne alors que vous n’avez jamais eu besoin de préciser l’encodage de la connexion, mais il est aussi possible que toutes vos données soient corrompues sans que vous le sachiez.

Prenons l’exemple de « Loïck » qui s’inscrit sur votre site web UTF-8, dans une table UTF-8, mais avec une connexion Latin1 :

  • INSERT Loïck en UTF-8 : 6 octets, 5 chars ;
  • Transmission en latin1, une conversion est faite ;
  • Loïck est transmis en Loïck : 6 octets, 6 chars ;
  • Loïck est stocké en UTF-8 : 8 octets, 6 chars ;
  • Puis nous faisons un SELECT ;
  • Loïck UTF-8 transmit en latin1 : 6 octets, 6 chars ;
  • Loïck affiché en UTF-8, ça donne Loïck !

Vous serez d’accord pour dire que préciser l’encodage de la connexion est obligatoire ! L’ORM Doctrine, par exemple, appelle déjà SET NAMES utf8; si vous avez précisé une option de charset.

Section intitulée votre-responsabilite-de-developpeur-webVotre responsabilité de développeur Web

Ce tweet de Milorad Ivović illustre très bien le problème : il ne peut pas s’inscrire sur un site gouvernemental Australien en utilisant son propre nom de famille. U+0107 LATIN SMALL LETTER C WITH ACUTE fait partie intégrante d’Unicode depuis la version 1.1, mais le site en question a décidé que seul l’ASCII est autorisé… Et propose comme exemple le nom le plus commun qu’il soit : « Smith ».

Unicode propose près de 260 000 possibilités, et c’est votre responsabilité d’être inclusif : votre application Web doit être utilisable par tout le monde, quel que soit le nom, l’origine ou la culture de vos visiteurs.

Le seul argument valable aujourd’hui pour faire une telle erreur serait la sécurité et les quelques tracas qu’un username « 🍕 » pourrait causer. Mais en suivant quelques recommandations simples, le support complet d’Unicode ne devrait pas poser de problème.

Section intitulée la-normalisationLa normalisation

Vous voulez donc supporter Unicode à fond et autoriser « 🍕 » comme username. C’est une très bonne nouvelle, mais vous voulez aussi vous assurer que cette pizza est unique dans votre base d’utilisateurs. Comme vous le savez, vos utilisateurs vont saisir n’importe quoi et vous pourriez avoir des collisions d’identifiant sans en comprendre la raison.

Il existe deux sources de collision possibles :

  • les homoglyphs ;
  • les glyphs composés.

La composition, on l’a déjà vu, c’est le fait d’écrire « é » de deux façons :

  • é : U+00E9 LATIN SMALL LETTER E WITH ACUTE ;
  • e + ◌́ : U+0065 LATIN SMALL LETTER E suivi de U+0301 COMBINING ACUTE ACCENT.

Les homoglyphs sont encore plus vicieux : il s’agit de caractères qui sont visuellement identiques mais qui ont des codes points différents (signification différente) :

  • K : U+212A KELVIN SIGN ;
  • K : U+004B LATIN CAPITAL LETTER K.

Ou encore :

  • Ω : U+03A9 GREEK CAPITAL LETTER OMEGA
  • Ω : U+2126 OHM SIGN

Visuellement, toutes ces variantes seront strictement identiques, et si l’utilisateur s’est inscrit avec la seconde forme, vous pouvez être sûr qu’il voudra se connecter avec la première. C’est votre rôle d’utiliser la normalisation appropriée.

La normalisation est l’étape qui consiste à rendre é et e + ◌́   identique afin de pouvoir les comparer et les trier de façon correcte. Toutes les règles de transformation sont déjà dans le standard, il est donc plutôt simple de les appliquer dans de nombreux langages :

$ARing = "\xC3\x85"; // Å (U+00C5)
$ARingComposed = "A"."\xCC\x8A";  // A◌̊ (U+030A)

$norm1 = Normalizer::normalize(
    $ARing, Normalizer::FORM_C
);
$norm2 = Normalizer::normalize(
    $ARingComposed, Normalizer::FORM_C
);

var_dump($ARing === $ARingComposed); // FALSE
var_dump($norm1 === $norm2); // TRUE

L’important est d’être consistant, et à jour : si vous normalisez à un endroit, il faut normaliser partout. Pas comme GitHub…

En 2016, leur système de rappel de mot de passe a été victime d’une attaque par homoglyph. L’email saisi était normalisé au moment du SELECT en base de données, mais le lien de rappel était envoyé à l’email d’origine, non normalisé. Un utilisateur malveillant ayant pour cible mike@example.org pouvait donc changer le mot de passe du compte en s’inscrivant avec miᏦᎬ@example.org, un email composé d’homoglyphs.

Section intitulée stockage-et-mysqlStockage et MySQL

Quelques failles liées à Unicode peuvent aussi être liées au stockage. Prenons par exemple Wordpress. Le moteur de blog accepte des commentaires en HTML, et en 2014 cela a ouvert une faille XSS liée au support d’Unicode de MySQL. Oui, il était possible de hacker Wordpress avec une part de 🍕 !

Soumettre ce commentaire passe la validation du HTML (c’est du code valide) :

<abbr title='🍕'>H4x0r</abbr>

Mais lors de l’insertion en base de donnée, seul le début : &lt;abbr title=' était écrit dans la table de commentaire ! Cette balise mal fermée provoque donc un bug de HTML qui va être exploitable en insérant un second commentaire, toujours valide, qui peut alors écrire n’importe quel attribut HTML :

coucou' onmouseover='alert(1)'

Même problème en 2014 sur Phabricator, dont le système d’inscription limité par nom de domaine d’adresse email pouvait être trompé via l’ajout d’un caractère mal stocké par MySQL.

En s’inscrivant avec attacker@gmail.com🍕@allowed-domain.com la vérification du domaine passait bien mais seul attacker@gmail.com était écrit dans la table MySQL.

Ces deux failles de sécurité ont la même source : le charset utf8 de MySQL ! Ce charset est encore utilisé par défaut sur de nombreuses installations mais il a un grave problème : il ne supporte pas UTF-8 en entier, seulement la BMP (les 65k code points les plus utiles) !

Autre problème, le mode strict de MySQL est lui aussi désactivé par défaut. Cette combinaison explosive fait en sorte que si nous essayons d’insérer un caractère hors BMP, notre chaîne est tronquée à partir de ce caractère et aucune erreur n’est remontée.

La solution est très simple : vous devez utiliser le charset utf8mb4 ! Si vous utilisez encore utf8 votre site ne supporte pas les emoji, il y a un grave problème.

Section intitulée pour-conclurePour conclure

  • Go Unicode : soyez inclusif et faites l’effort nécessaire pour supporter les internautes du monde entier ;
  • Forcez le charset de vos connexions MySQL ;
  • Arrêtez d’utiliser le charset utf8 sur vos tables, il est mal nommé, obsolète et dangereux ;
  • Utilisez utf8mb4, c’est le bien ;
  • Normalisez les contenus important, pour trier, comparer, de façon homogène ;
  • Forcez l’encodage pour tous vos développeurs (avec un petit EditorConfig par exemple) ;
  • N’utilisez pas utf8_decode et utf8_encode !

L’encodage et les problèmes qui en découlent sont relativement peu étudiés mais très présents sur le Web, et j’ai beaucoup appris en préparant cet article, il y a un réel manque de pédagogie sur le sujet.

Le travail du Consortium Unicode est absolument dingue, et les outils pour faire de la normalisation, du tri, de la comparaison, de la détection de mot… sont aussi rendus disponibles gratuitement (ICU). Si vous le souhaitez, vous pouvez supporter leur travail via un sponsoring de caractère – pour notre part, nous avons sponsorisé l’emoji 🍻 !

Unicode sponsor

Et maintenant, je vous invite à toujours essayer de vous inscrire avec le username 🍕 sur les sites que vous développez !

Commentaires et discussions

Ces clients ont profité de notre expertise