Au chapitre des éléments les plus contestés (contestables ?) en matière d'accessibilité, les Completely Automated Public Turing-test to tell Computers and Humans Aparts se trouvent en très bonne position. L'article les Completely Automated Public Turing-test to tell Computers and Humans Aparts et l'accessibilité sur le site « Accessibilité Numérique Orange » en parle très bien.
Le paradoxe du Completely Automated Public Turing-test to tell Computers and Humans Apart est que c'est un robot qui vous demande de prouver que vous êtes un humain...
D'un autre côté, il faut bien protéger un peu les formulaires pour éviter les messages indésirables et autres joyeuses attaques.
Parmi les solutions alternatives, une des plus simples à mettre en œuvre est la combinaison de techniques :
-
Le Honeypot, ou Pot de miel. Cela consiste à ajouter un champ masqué à l’écran dans le formulaire mais qui reste « visible » pour un robot.
-
Le Time Measuring, ou mesure de temps d'exécution. Principe simple qui consiste à calculer le temps de saisie du formulaire. Un programme automatisé va rarement temporiser l'envoi du formulaire. Alors à moins que vos utilisateurs soient capables de renseigner deux champs ou plus de formulaire en moins de 10 secondes, il est peu risqué de parier sur le fait qu'en dessous de ce temps, votre interlocuteur n'ait rien d'humain.
-
Et comme nous sommes joueurs, on va y ajouter une 3ème : l'empreinte de navigateur.
Les Content Security Policy
Une des toutes premières choses simples à mettre en œuvre, la directive Hypertext Transfer Protocol
Content-Security-Policy pour les formulaires (et le reste, d'ailleurs).
Les explications claires se trouvent sur la page de Mozilla Developer Network Web Docs.
Exemple de Content Security Policy pour ce site :
Content-Security-Policy: default-src 'self'; […]; form-action 'self'; […]
Empreinte numérique
Une empreinte numérique, c'est quoi ? Appelée aussi browser fingerprint, c'est un outil pour « identifier » un utilisateur, plus ou moins contre son gré, à des fins approximativement avouables…
C'est aussi un outil parfaitement louable pour d'autres besoins plus techniques. En l'occurrence, ici, on s'en sert pour essayer de limiter le spam via le formulaire de contact. Et ça a l'air de fonctionner. Bon ok, il n'y a pas que ce moyen, mais c'est déjà un pare-feu.
En pratique, ça donne quoi ? En général, un utilisateur possède un écran, un navigateur et un pointeur ou des doigts. Ou pas… C'est d'ailleurs pour ça que la solution du Completely Automated Public Turing-test to tell Computers and Humans Apart n'est pas la plus sympathique.
Calculer une empreinte numérique peut permettre de vérifier ces points et de générer un code quasiment unique.
La fonction window.finger() va donc renvoyer une valeur ou bien null. Dans le second
cas, on considère qu'il ne s'agit pas d'un utilisateur réel et le formulaire reste désactivé.
C'est déjà un premier blocage pour les robots.
Dans le cas contraire, on peut retirer dynamiquement l'attribut disabled du fieldset.
Votre identifiant est d'ailleurs .
Redimensionnez votre navigateur, changez le mode d'affichage sombre ou lumineux par exemples, pour visualiser le changement d'empreinte.
Le code qui suit, permet de renvoyer un « identifiant » de navigateur basé sur les propriétés de ce dernier qui sont ensuite passées à la moulinette pour sortir un hash ou null si certaines propriétés ne sont pas détectées et qui amènent à penser qu'on a plus à faire à un robot qu'un utilisateur réel.
Si tout se passe bien, on a quelque chose qui ressemble à ça :
- Empreinte de base : null
- Empreinte préfixée : null
Si vous voyez s'afficher « Cool ! Vous semblez être un humain. », bonne nouvelle, vous êtes vivant ! Sinon, nous n'avons rien à nous dire…
Voilà voilà… pas grand chose d'autre à ajouter si ce n'est qu'il ne vous reste plus qu'à trouver quoi en faire. Ici, on s'en sert aussi à des fins statistiques pour comptabiliser les visiteurs uniques.
A priori, la lecture du script doit suffire à sa compréhension.
;(() => {
'use strict';
const hashCodeFinger = (str, salt = 0) => {
let h1 = 0xdeadbeef ^ salt,
h2 = 0xb16b00b5 ^ salt;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};
/*
On commence par lire des propriétés du terminal.
Normalement, c'est plutôt clair :-)
*/
window.finger = (prefix = 'FP_UID_', salt = 0) => {
const fs = (parseInt(window.getComputedStyle(document.documentElement, null).getPropertyValue("font-size")) / 16) || null;
/*
Vérifier si un écran existe et que le navigateur implémente
l'API clipboard = on est sur un terminal physique (probablement…)
ATTENTION : pour l'accessibilité, ça peut poser un problème, car
un non voyant peut ne pas avoir d'écran connecté.
*/
if (typeof window.outerWidth === 'number'
&& typeof navigator.clipboard === 'object'
&& typeof screen.width === 'number'
&& fs !== null) {
let fingerProperties = [
screen.width,
screen.height,
window.outerWidth,
window.innerWidth,
window.outerHeight,
window.innerHeight,
fs,
// devicePixelRatio dppx
window.devicePixelRatio,
// ratio de la fenêtre d'affichage
window.innerWidth / window.innerHeight >= 1,
// serait-ce un écran tactile ?
'ontouchstart' in window,
// mode sombre activé ou pas
window.matchMedia("(prefers-color-scheme: dark)").matches,
// + de 95% des navigateurs ont les animations actives
window.matchMedia("(prefers-reduced-motion: reduce)").matches
];
let data = [];
data.push(navigator.userAgent, navigator.language, (navigator.doNotTrack || 'null'));
for (let i in fingerProperties) {
data.push(fingerProperties[i].toString());
}
return prefix + hashCodeFinger(data.join(''), salt);
} else {
// oups… ça n'a pas l'air d'être un navigateur là !
return null;
}
};
})();
Le Honeypot
La partie la plus simple : ajouter un champ fictif dans le formulaire.
<!--
la classe clip ne doit pas masquer le label,
seulement le faire disparaitre visuellement comme les habituels screen-readers, visually-hidden, etc.
-->
<label class="clip" tabindex="-1">
Confirmez votre adresse courriel
<input type="text" value="" aria-hidden="true" id="email_honey" tabindex="-1">
</label>
Ce champ est inclus dans son label.
Les attributs nécessaires pour l'accessibilité :
- Sur le
label: attributsaria-hidden="true"ettabindex="-1"pour que les lecteurs d'écran l'ignorent et qu'ils ne prennent pas le focus en naviguant au clavier. - Sur le champ de saisie : attribut
tabindex="-1"pour le sortir de la navigation clavier.
Et voilà pour la partie accessibilité.
La mécanique d'exécution
- Au chargement de la page, l'empreinte numérique est calculée. Si elle renvoie
null, le champ est alimenté avec une valeur aléatoire. Cette partie est à gérer de votre côté. - Si l'empreinte n'est pas égale à
null, les attributsdisabledesfieldsetsont retirés dynamiquement. La saisie devient donc possible dans le formulaire. - Une mesure de temps est posée au chargement de la page via un script.
- Quand le formulaire est envoyé, on vérifie que :
- le honeypot est vide ;
- la durée écoulée est supérieure à 10 secondes (valeur par défaut) ou bien à la valeur spécifiée sur la balise forme avec l'attribut
data-time-execution.
Le formulaire final
Avant l'exécution des scripts, le formulaire ressemble un peu à ça.
Après le chargement, vous voyez un formulaire tout à fait normal. C'est transparent pour l'utilisateur qui n'a aucune action à produire.
Pour la joliesse de l'exemple, la balise legend est masquée, mais reste lisible pour lecteurs d'écran.
Le code du formulaire :
<form data-action="#" data-time-execution="5" novalidate id="form-contact" spellcheck="false" class="outer-line box-shadow-thin mt-h graphic-background">
<p class="h3">Petit formulaire</p>
<!--
span point d'ancrage des messages d'erreurs uniquement pour la démo
-->
<span class="clip" id="form-contact-errors"></span>
<fieldset disabled>
<legend class="screen-readers">Identité</legend>
<div class="flex half-gutter justify-content-stretch hidden-describe-input">
<div>
<label for="form-contact-firstname">
Prénom
</label>
<input autocomplete="given-name" aria-describedby="firstname-desc" aria-required="true" type="text"
id="form-contact-firstname"
enterkeyhint="next"
autocapitalize="characters">
<p class="describe-input" id="firstname-desc">
Saisir votre prénom
</p>
</div>
<div>
<label for="form-contact-name">
Nom
</label>
<input autocomplete="family-name" aria-describedby="name-desc" aria-required="true" type="text" id="form-contact-name" enterkeyhint="send" autocapitalize="characters">
<p class="describe-input" id="name-desc">
Saisir votre nom de famille
</p>
</div>
</div>
<label class="clip" tabindex="-1">
Confirmez votre adresse courriel
<input type="text" value="" aria-hidden="true" id="email_honey" tabindex="-1">
</label>
<div class="s-12 flex flex-center justify-content-space-between no-margin-bottom no-fit">
<button type="reset" class="cta-icon cta-text cta-small no-fit xs-left">
<svg aria-hidden="true" width="32" height="32">
<use href="#icon-reload"/>
</svg>Rétablir<span class="screen-readers"> le formulaire</span></button>
<button type="submit" class="cta-icon cta-tertiary no-fit">
<svg aria-hidden="true" width="32" height="32">
<use href="#icon-check"/>
</svg>
Valider</button>
</div>
</fieldset>
</form>
Le script d'activation et de vérification
Pour l'exemple, le script utilise des fonctions de gestion des messages d'erreurs que vous pourrez retrouver sur la page dédiée aux formulaires accessibles.
Pour terminer, l'envoi du formulaire peut se faire en asynchrone vers une page de traitement qui n'autorise que des requêtes protégées avec Same-origin policy, en complément de directives Content-Security-Policy script-src.
;(() => {
'use strict';
let root = document.getElementById('form-contact'),
firstname = root.querySelector('#form-contact-firstname'),
name = root.querySelector('#form-contact-name'),
honeypot = root.querySelector('#email_honey'),
point = root.querySelector('#form-contact-errors'),
posts = 0;
if (window.finger() !== null) {
root.querySelectorAll('fieldset').forEach(function (el) {
el.removeAttribute('disabled');
});
} else {
honeypot.value = 'INPUT-' + window.finger() + '-' + Math.random().toString(36).slice(2);
}
/*
initialisation du time measuring
*/
const startTime = Date.now();
root.addEventListener('submit', e => {
e.preventDefault();
if (honeypot.value.length) {
// apparemment, c'est un robot qui regarde la page
return;
}
/*
Si le test honeypot n'a pas fonctionné
et que le champ est resté vide, on va vérifier
le temps passé sur le formulaire.
Par défaut, 10 secondes, sinon lecture de l'attribut
data-time-execution posé sur la balise form
*/
let getTimeExecution = Number(root.getAttribute('data-time-execution')) || 10,
timeExecution = Math.abs((Date.now() - startTime) / 1000).toFixed(0);
if (timeExecution < getTimeExecution) {
return;
}
/*
*
* À PARTIR DE LÀ, C'EST À VOUS DE CODER VOS FONCTIONS DE CONTRÔLE
*
*
*
* suppression des aria-invalid posés par la fonction
* de gestion des messages d'erreurs
*/
root.querySelectorAll('[aria-invalid]').forEach(el => el.removeAttribute('aria-invalid'));
root.querySelectorAll('.error').forEach(el => el.classList.remove('error'));
window.remove_errors();
let messages = [];
if (!firstname.value.length) {
firstname.classList.add('error');
messages.push([i18n['form_firstname_error'], firstname.id]);
}
if (!name.value.length) {
name.classList.add('error');
messages.push([i18n['form_name_error'], name.id]);
}
if (messages.length) {
window.form_errors({
point: point,
messages: messages,
headingLevel: 3,
css: 'error',
tag: 'ol'
});
/*
Le temps de saisie est ok.
Mesure supplémentaire : test du nombre d'envois
du formulaire. Si 5 essais -> on reload la page.
*/
if (posts === 3) {
window.snackBar?.({
content: `Vous avez effectué ${posts + 1} essais.<br>La page va être rechargée au prochain essai infructueux<br>et votre saisie sera perdue.`,
hide: false,
type: 'html',
width: 'max(50vw, 25rem)',
position: 'top-left'
});
posts++;
return;
}
if (posts === 4) {
// trop tentatives d'envoi, c'est louche…
document.location.reload();
return;
}
posts++;
return;
}
root.reset();
root.querySelector('[type=reset]').click();
posts = 0;
window.snackBar?.({content: 'Et voilà ! Tout s\'est bien passé !', hide: false, modal: true})
});
root.querySelector('[type=reset]').addEventListener(
'click', () => {
// Supprime tous les attributs aria-invalid
root.querySelectorAll('[aria-invalid]').forEach(el => el.removeAttribute('aria-invalid'));
// Supprime la classe 'error' si elle est présente
root.querySelectorAll('.error').forEach(el => el.classList.remove('error'));
window.remove_errors('message-dialog');
point.setAttribute('tabindex', '-1');
point.focus();
}
);
})();
A priori, ça doit fonctionner puisque le formulaire de contact de ce site ne renvoie aucun spam…