Aller au contenu
Sylvain Joffraud l4w
Retour

iOS Safari injecte une NBSP : la regex `\s` ne la voit pas

Un PO remonte un bug sur un tunnel de souscription en ligne : son numéro de téléphone est rejeté côté serveur, alors qu’il s’affiche correctement à l’écran et que le drapeau pays est bien sélectionné. Impossible à reproduire sur nos postes (Chrome, Firefox desktop). Le numéro est valide, visuellement irréprochable. Et pourtant, 422.

Symptôme

Le composant front utilisait intl-tel-input (le widget avec drapeau + indicatif). Côté serveur, une validation maison à base de regex, du genre :

// Validation "maison" du numéro normalisé
if (preg_match('/^\+\d{1,3}[\s\d]{6,}$/', $phone) !== 1) {
    throw new BadRequestHttpException('Numéro de téléphone international invalide');
}

La regex semblait propre : un +, un indicatif, puis des chiffres et des espaces. Sauf que pour ce PO — et seulement sur son device — elle ne matchait jamais.

Le réflexe qui débloque tout : ne pas regarder le numéro affiché, mais ses octets bruts. Un bin2hex() sur la valeur reçue côté serveur :

error_log(bin2hex($phone));
// 2b3333 c2a0 363831353930323336
//  +  3 3 ↑↑↑↑  6  8  1  5  9  0 ...
//        \____ C2 A0 = UTF-8 de U+00A0 (NBSP)

c2a0, c’est l’encodage UTF-8 de U+00A0 — l’espace insécable (NBSP). iOS Safari l’avait injectée entre l’indicatif et la suite du numéro : +33\u{00A0}681590236.

Cause

intl-tel-input (et/ou le clavier iOS) formate le numéro à l’affichage en insérant une espace insécable comme séparateur visuel entre l’indicatif et le reste. C’est joli à l’écran. Mais côté serveur, \s en PCRE (mode par défaut, sans /u) ne matche que les espaces ASCII : espace, tab, CR, LF, FF, VT. U+00A0 n’en fait pas partie.

Résultat : un caractère invisible, indistinguable d’une espace normale à l’œil, fait échouer une regex qui « a l’air » correcte. Et comme nos claviers desktop n’injectent jamais de NBSP, le bug est invisible en dev et ne sort que sur device iOS réel.

C’est un piège classique de la validation par regex sur de la donnée saisie : on raisonne sur les caractères qu’on voit, pas sur les octets qui arrivent.

Solution

On a arrêté de jouer au chat et à la souris avec les espaces Unicode. La validation d’un numéro de téléphone est un problème déjà résolu : libphonenumber (la bibliothèque de Google, portée en PHP), la même autorité que celle utilisée par intl-tel-input côté JS.

use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

final class PhoneNumberValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (null === $value || '' === $value) {
            return;
        }

        $phoneUtil = PhoneNumberUtil::getInstance();

        try {
            $number = $phoneUtil->parse($value, $constraint->defaultRegion);

            if (!$phoneUtil->isValidNumber($number)) {
                $this->context->buildViolation($constraint->message)->addViolation();
            }
        } catch (NumberParseException) {
            $this->context->buildViolation($constraint->message)->addViolation();
        }
    }
}

libphonenumber strippe lui-même les séparateurs (espaces ASCII, NBSP, points, tirets), comprend les formats locaux et E.164, et valide le numéro pour de vrai (longueur correcte selon le pays, préfixe d’opérateur plausible…). Plus de regex à maintenir.

Pour le besoin spécifique « normaliser vers E.164 » (un prestataire de signature électronique en aval exigeait du +33… strict), on a aussi extrait un petit Value Object immutable qui nettoie d’abord les séparateurs courants, avant de valider la forme :

final class PhoneE164
{
    private const STRIP_PATTERN   = '/[\s\.\-]/u';      // /u → \s couvre aussi le NBSP
    private const E164_FULL_PATTERN = '/^\+\d{8,15}$/';
    private const FR_LOCAL_PATTERN  = '/^0\d{9}$/';

    private function __construct(public readonly string $value) {}

    public static function fromUserInput(string $raw): self
    {
        $clean = preg_replace(self::STRIP_PATTERN, '', $raw) ?? '';

        if ($clean === '') {
            throw new \InvalidArgumentException('Numéro de téléphone vide');
        }

        if (str_starts_with($clean, '+')) {
            if (preg_match(self::E164_FULL_PATTERN, $clean) !== 1) {
                throw new \InvalidArgumentException("Numéro E.164 invalide : \"$raw\"");
            }
            return new self($clean);
        }

        if (preg_match(self::FR_LOCAL_PATTERN, $clean) === 1) {
            return new self('+33' . substr($clean, 1));   // 0612345678 → +33612345678
        }

        throw new \InvalidArgumentException("Numéro non reconnu : \"$raw\"");
    }
}

Le détail qui compte : le flag /u sur le STRIP_PATTERN. En mode Unicode, \s couvre aussi U+00A0 — donc le NBSP est éliminé au nettoyage, avant même la validation de forme. Et un jeu de tests qui couvre explicitement le cas du PO :

public static function provideValidInputs(): iterable
{
    yield 'FR compact'       => ['0612345678',        '+33612345678'];
    yield 'FR espaces'       => ['06 12 34 56 78',    '+33612345678'];
    yield 'FR points'        => ['06.12.34.56.78',    '+33612345678'];
    yield 'E164 avec NBSP'   => ["+33\u{00A0}612345678", '+33612345678']; // le bug iOS
    yield 'UK E164'          => ['+44123456789',      '+44123456789'];
}

Lessons learned

#ios #safari #php #symfony #validation #libphonenumber #ux


Partager cet article :

Article précédent
Wording UX : erreurs inline par champ sur des uploads
Article suivant
Tunnel SSH invisible des conteneurs Docker : bind 0.0.0.0