Implémenter un leurre anti-spam dans un formulaire Symfony

Publié le 07/12/2019 • Mis à jour le 07/12/2019

Dans cet article, nous allons voir comment implémenter un leurre anti-spam de type "honeypot". Nous allons faire un test sur un formulaire d'inscription à une newsletter ne contenant qu'un champ email. Nous allons ausi logger ce qui a été bloqué afin de vérifier que tout fonctionne correctement. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧

» Publié dans "Une semaine Symfonique 675" (du 2 au 8 décembre 2019).

Prérequis

Je supposerai que vous avez une connaissance basique de Symfony et que vous savez comment créer un formulaire à l'aide d'une classe.

Configuration

  • PHP 7.4
  • Symfony 5.0.7

Introduction

Le spam est partout aujourd'hui et les bots sont de plus en plus sophistiqués. J'ai récemment reçu un mail d'un spammeur me disant que la protection Google-captcha que j'avais implémentée sur l'un de mes projets était inutile et qu'elle pouvait facilement être cassée avec des outils spécialisés. Et, en effet, j'ai eu quelques comptes créés ressemblant à du spam et n'ayant jamais été validés.
Nous allons voir une astuce permettant de réduire le spam que vous êtes susceptibles d'avoir sur vos formulaires publics. Bien sûr, il y a d'autres moyens, mais comme c'est assez rapide à mettre en place, autant le faire. C'est parti ! 🙂

Création de la classe de formulaire

Tout d'abord nous devons créer la classe Type de notre formulaire. Nous allons ajouter deux champs : l'email et le leurre. Regardons le code :

<?php declare(strict_types=1);

// src/Form/NewsletterType.php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;

/**
 * Newsletter subscribe form with honeypot.
 */
final class NewsletterType extends AbstractType
{
    public const HONEYPOT_FIELD_NAME = 'email';
    public const EMAIL_FIELD_NAME    = 'information';

    public function buildForm(FormBuilderInterface $b, array $options): void
    {
        $b->add(self::EMAIL_FIELD_NAME, EmailType::class, [
            'required' => true,
            'constraints' => [
                new NotBlank(),
                new Email(['mode' => 'strict']),
            ],
        ]);
        $b->add(self::HONEYPOT_FIELD_NAME, TextType::class, ['required' => false]);
        $b->setMethod(Request::METHOD_POST);
    }
}

Nous utilisons des constantes afin de définir le nom des champs. Nous inversons ces noms par rapport à ceux que nous mettrions d'habitude. A savoir que le champ email porte le nom "information" alors que le leurre porte le nom "email". Il est important que ce dernier champ porte bien ce nom pour que les robots puissent le détecter. Et, comme c'est le plus commun pour un champ de type email, ils y parviendront à coup sûr. C'est ce que nous voulons.

Affichage du formulaire sur la page

Maintenant, nous pouvons afficher le formulaire. Le but est de cacher le leurre afin qu'uniquement les robots puissent le remplir. Cliquez sur le bouton à droite pour le voir ou le cacher. Essayez d'y mettre quelque chose puis validez, vous aurez un message indiquant que du spam a été détecté. En vrai, bien sûr, on n'affichera pas un tel message en cas de détection.

Voilà le template de ce formulaire :

{% trans_default_domain 'post_59' %}

{% include '_flash.html.twig' %}

<div class="row" id="vue-59">
    <div class="col-lg-6 col-md-6 col-sm-8 ml-auto mr-auto">
        <div class="card">
            <div class="card-body">
                {{ form_start(form, {action: path('blog_59_action', {slug: slug, '_fragment': 'form'})}) }}
                    <div class="form-group">
                        {{ form_label(form.information, 'email_label'|trans) }}
                        {{ form_widget(form.information, {attr: {class: 'form-control', placeholder: 'email_placeholder'|trans}}) }}
                        {{ form_errors(form.information) }}
                    </div>

                    <div class="form-group" v-show="show_honey_pot">
                        {{ form_label(form.email, 'honey_pot_label'|trans) }}
                        {{ form_widget(form.email, {attr: {class: 'form-control', style: 'display:none !important', tabindex: '-1', autocomplete: 'off', ref: 'init'}}) }}
                    </div>

                    <div class="card-footer justify-content-center">
                        <button type="submit" class="btn btn-primary"><i class="fa fa-user-alt"></i> {{ 'subscribe'|trans }}</button>

                        <button class="btn btn-default" v-on:click.prevent="switchHoneyPot"><i class="fa fa-eye"></i> { honey_pot_button_label }</button>
                    </div>
                {{ form_end(form) }}
            </div>
        </div>
    </div>
</div>

Quelques explications :

  • On affiche le widget "information" comme nous avons l'habitude de le faire. C'est en fait, le véritable widget email.
  • Ensuite, on affiche le leurre, mais on le cache via CSS en affectant la valeur display:none à la propriété style en appelant le helper form_widget.

Bien sûr, tout le bloc correspondant au leurre devrait être caché. J'ai mis un label juste pour rendre l'article plus clair. Il y a un petit script vue.js qui prend en charge le bouton de permutation de l'affichage du leurre. Ce n'est pas le sujet de cet article. (veuillez quand même noter que le code vue.js est entre de simple accolades {} alors que les doubles accolades sont réservées pour Twig).

Cliquez ici pour voir le code JavaScript.
{% trans_default_domain 'post_59' %}

<script>
    /*global $, console, $http */
    /*jslint browser:true */
    "use strict";
    let vue = new Vue({
        delimiters: ['{', '}'],
        el: '#vue-59',
        data: {
            show_honey_pot: false,
        },
        computed: {
            honey_pot_button_label: function () {
                return this.show_honey_pot ? '{{ 'hide_honey_pot'|trans }}' : '{{ 'show_honey_pot'|trans }}';
            }
        },
        methods: {
            switchHoneyPot: function () {
                this.show_honey_pot = !this.show_honey_pot;
            }
        },
        mounted() {
            // force show because I wanted to keep "display: none" to avoid confusing people reading the post
            this.$refs.init.style.display = '';
        }
    });
</script>

Détection du spam

Quand on traite le formulaire, on récupère deux valeurs : l'email et le leurre. Le test est donc simple, c'est du spam si quelque chose est dans le leurre quelle que soit sa valeur. Voilà le code :

<?php declare(strict_types=1);

// src/Controller/Post/Post59Trait.php

namespace App\Controller\Post;

use App\Data\ArticleData;
use App\Entity\Organization;
use App\Twig\Extension\SlugExtension;
use App\Form\NewsletterType;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Functions for the 59th blog post.
 *
 * @property ArticleData          $articleData
 * @property LoggerInterface      $slackLogger
 * @property SlugExtension        $slugExtension
 * @property FormFactoryInterface $formFactory
 */
trait Post59Trait
{
    /**
     * Route prefix is in the main BlogController, so the real route name here is "blog_59_action".
     *
     * @Route("/59/{slug}", name="59_action", methods={"POST"})
     */
    public function action59(Request $request): Response
    {
        $routeParams = $request->get('_route_params'); // because of the trait you know.
        $refSlug = $this->slugExtension->getArticleSlug($routeParams['slug'], $routeParams['_locale']);
        $data = $this->articleData->getShowData($refSlug, $routeParams['slug']);

        $form = $this->formFactory->create(NewsletterType::class)->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            [NewsletterType::EMAIL_FIELD_NAME => $email, NewsletterType::HONEYPOT_FIELD_NAME => $honeyPot] = $form->getData();
            if (empty($honeyPot)) {
                // Not a spam, save email, etc...
                $org = (new Organization())->setName($email);
                $this->addFlash('success', sprintf('Thank you "%s"! See you later on 🐝️!', $org->getName()));
            } else {
                // Spam detected!
                $warning = sprintf('🐛 SPAM detected: email: "%s", honeypot content: "%s"', $email, $honeyPot);
                $this->slackLogger->warning($warning);
                $this->addFlash('warning', $warning);
            }

            return $this->redirectToRoute('blog_show', ['slug' => $data['slug'], '_fragment' => 'form']);
        }

        // Email is invalid then display the errors
        $data['form'] = $form->createView();
        $data['has_js'] = true;

        return $this->render('blog/post.html.twig', $data);
    }

Quelques explications :

  • On teste si le formulaire a été soumis et est valide.
  • On récupère les valeurs de l'email et du leurre à l'aide des constantes que nous avons définies dans la classe de formulaire.
  • Si le leurre n'est pas vide, nous créons un message d'avertissement flash et nous envoyons une notification par slack.
  • Si le leurre est vide, on peut continuer avec le traitement normal (stockage en base de données...).
  • Si le formulaire n'est pas valide, on affiche les erreurs comme on a l'habitude de le faire.

La notification slack que je reçois quand le leurre contient quelque chose.
La notification slack que je reçois quand le leurre contient quelque chose.

Test du leurre

Maintenant que notre formulaire fonctionne. Assurons-nous que ce soit toujours le cas en ajoutant quelques tests pertinents. Commençons par les tests unitaires :

<?php declare(strict_types=1);

// tests/Type/Post59UnitTest.php

namespace App\Tests\Type;

use App\Form\NewsletterType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;

/**
 * @covers NewsletterType
 */
final class Post59UnitTest extends KernelTestCase
{
    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->formFactory = self::$container->get('form.factory');
    }

    /**
     * @covers NewsletterType::buildForm
     */
    public function testNewsletterType(): void
    {
        $form = $this->formFactory->create(NewsletterType::class);
        $form->submit([
            'information' => 'foo@bar.com',
            'email' => 'honeypot',
        ]);
        $this->assertTrue($form->isSynchronized());
        $children = $form->createView()->children;
        $this->assertArrayHasKey('information', $children);
        $this->assertArrayHasKey('email', $children);
        $this->assertArrayHasKey('_token', $children);
    }
}

Ces test unitaires sont basiques. On créé une instance du formulaire newsletter et nous soumettons des valeurs. Nous vérifions qu'aucune erreur n'a été rencontrée lors de processus de création (isSynchronized()) et que tous les widgets sont disponibles pour la vue (l'email, le leurre et le jeton CSRF). Je ne le savais pas, mais on ne peut pas tester correctement la validation dans un test unitaire. Mais ce n'est pas un problème puisque ce sera fait dans les tests fonctionnels suivants :

<?php declare(strict_types=1);

// tests/Controller/Post/Post59TraitTest.php

namespace App\Tests\Controller\Post;

use App\Controller\Post\Post59Trait;
use App\Tests\WebTestCase;

/**
 * @see Post59Trait
 */
class Post59TraitTest extends WebTestCase
{
    public function provide(): \Iterator
    {
        //      email,           honeypot       => feedback message
        yield ['for@bar.com',    '',               'Thank you!'];                              // 1. Nominal case
        yield ['invalid-email',  '',               'This value is not a valid email address']; // 2. Standard validation
        yield ['spam@yahoo.com', 'spam@yahoo.com', 'SPAM detected'];                           // 3. Honeypot detection
    }

    /**
     * @covers Post59Trait::action59
     *
     * @dataProvider provide
     */
    public function testAction59(string $email, string $honeyPot, string $feedback): void
    {
        $client = static::createClient();
        $token = $client->getContainer()->get('security.csrf.token_manager')->getToken('newsletter')->getValue();
        $client->request('POST', '/en/blog/59/implementing-a-honeypot-in-a-symfony-form', [
            'newsletter' => [
                'information' => $email,
                'email' => $honeyPot,
                '_token' => $token
            ],
        ]);

        // No redirect if the form is invalid (case 2)
        if ($client->getResponse()->isRedirect()) {
            $client->followRedirect();
        }
        $this->assertTrue($client->getResponse()->isSuccessful());
        $this->assertStringContainsStringIgnoringCase($feedback, $client->getResponse()->getContent());
    }
}

Dans ces tests fonctionnels, nous utilisons un fournisseur de données (dataProvider). C'est une bonne pratique et cela nous permet d'éviter d'écrire des boucles. Le fournisseur de données a la charge de passer les valeurs à tester et le résultat attendu. Pour chaque jeu de données, on soumet au formulaire ces valeurs puis nous vérifions le code de retour de la réponse ainsi que le message de retour renvoyé dans la réponse. J'utilise la fonction yield par souci de clareté mais aussi pour économiser deux lignes (return + []). Lançons tout ceci :

[22:49:57] coil@Mac-mini-de-COil.local:/Users/coil/Sites/strangebuzz.com$ ./bin/phpunit --filter=Post59 --debug
#!/usr/bin/env php
PHPUnit 8.3.5 by Sebastian Bergmann and contributors.

Testing Project Test Suite
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #0 ('for@bar.com', '', 'Thank you!')' started
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #0 ('for@bar.com', '', 'Thank you!')' ended
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #1 ('invalid-email', '', 'This value is not a valid ema...ddress')' started
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #1 ('invalid-email', '', 'This value is not a valid ema...ddress')' ended
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #2 ('spam@yahoo.com', 'spam@yahoo.com', 'SPAM detected')' started
Test 'App\Tests\Controller\Post59TraitTest::testAction59 with data set #2 ('spam@yahoo.com', 'spam@yahoo.com', 'SPAM detected')' ended
Test 'App\Tests\Type\Post59UnitTest::testNewsletterType' started
Test 'App\Tests\Type\Post59UnitTest::testNewsletterType' ended

Time: 510 ms, Memory: 40.25 MB

OK (4 tests, 10 assertions)

Victoire ! Tout fonctionne correctement. 🎉 😀

Conclusion et quelques conseils

Cet article est désormais assez conséquent avec les exemples de code. Mais bien sûr, si vous avez un formulaire d'inscription, vous avez déjà probablement la plus grande partie du code nécessaire. Vous pouvez faire un essai en ajoutant le leurre et en permutant les noms de champs comme indiqué dans la permière partie. Depuis que j'ai implémenté ce leurre sur l'un de mes projets je n'ai plus de spam sur le formulaire d'inscription à la newsletter. J'ai aussi fait d'autres essais comme renommer le jeton CSRF mais ça n'a pas marché car en général les robots vont garder les valeurs par défaut qu'ils vont trouver dans les champs (à l'exception des boutons radios ou des listes de sélection ou ils préféreront choisir une valeur aléatoire).

Quelques autres conseils concernant les emails. Si vous avez un formulaire d'inscription et que vous envoyez un email de confirmation "Confirmez votre email" avec un lien, n'utilisez jamais une information provenant de l'utilisateur dans celui-ci. Par exemple n'écrivez pas "Boujour Fab Pot, merci de confirmer votre compte en cliquant sur ce lien". Sinon, des hackers pourraient utiliser votre formulaire afin de spammer en remplaçant les noms par des URLs.
Dans le cas d'un formulaire de type newsletter, quand un utilisateur souscrit, ne jamais envoyer la dernière édition disponible. De la même manière, des spammeurs pourraient utiliser votre formulaire afin de spammer les emails de vraies personnes afin de remplir leur boîte de réception afin qu'ils loupent un email important comme "Votre mot de passe a été changé pour le service xxx". Cette pratique est apellée le "mail-flooding".

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À la revoyure ! COil. 😊

  Lire la doc  Plus sur le web  Plus sur Stackoverflow

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : jmsche, keversc. 😊


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil