Implementing a honeypot in a Symfony form

Published on 2019-12-07 • Modified on 2019-12-07

In this post, we will see how to implement a simple honeypot in a Symfony form to avoid spam. We will try it on a newsletter form with a unique email field. We'll also log what is blocked by the trap to check if it works correctly. Let's go! 😎

» Published in "A week of Symfony 675" (2-8 December 2019).

Prerequisite

I will assume you have a basic knowledge of Symfony and that you know how to create a form with a type class.

Configuration

  • PHP 7.2
  • Symfony 4.4.2

Introduction

Spam is everywhere and nowadays bots are getting more and more advanced. I recently received a mail from a spammer saying that my Google-captcha protection on one of my side project was useless and he could easily break it with some specialized tools. And indeed, I had a few accounts that looked like liked spam and were never validated.
We will see a trick that will reduce the spam you may get on your public forms. Of course, there are many other methods but as it's easy to put in place, let's do it! 🙂

Creating the form type

First, we need to create a form type. It will add two fields: the email and the honeypot. Let's have a look at the code:

<?php declare(strict_types=1);

// src/Type/NewsletterType.php

namespace App\Type;

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);
    }
}

We use constants to define the names of the fields. We switch the names we would normally use for those two fields. So, now, the email has the "information" name. while the honeypot is named "email". This time, it's important for this field to have this name as bots will try to detect it. And, as it's the most common one, they will find it for sure. That's what we want.

Rendering the form on the page

Now we can render the form. The goal is to hide the honeypot widget so only bots will try to fill this field. Click on the button on the right to show/hide the honeypot on the form below. Now, try to put something in it, if you validate you will have a message showing that spam was detected. In reality, of course, you don't want to display this message.

And here is the template of this form:

{% 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>

Some explanations:

  • We render the "information" widget like we use to do. It is in fact, the real email widget.
  • Then, we render the honeypot, we hide it via CSS by setting the style property to display:none when calling the form_widget helper.

Of course, all the honeypot block should be hidden, I have put a label only for the tutorial. There is a small vue.js script to handle the show/hide button, but it's not the subject of this post (note that the vue.js code is between {} as {{ }} is reserved for Twig).

Click here to see the JavaScript code.
{% 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>

Detecting the SPAM

When processing the form, we get two values, one for the email and one for the honeypot. So, the test is straightforward. It is spam if there is something in the honeypot, whatever it is. Here is the 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\Type\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', 'Thank you! See you later on 🐝️!');
            } 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);
    }

Some explanations:

  • We test if the form is submitted and valid.
  • We get the email and honeypot values with the help of the constants we have defined in the form type.
  • If the honeypot is not empty, we create a warning flash message and we send a slack notification.
  • If the honeypot is empty we can continue with the normal process (store in the database...).
  • If the form is not valid, we display the errors as we are used to.

The slack notification I receive when the honeypot contains something.
The slack notification I receive when the honeypot contains something.

Testing the honeypot

Ok, now that our form works, let's be sure it will always be the case by adding some tests. Let's begin with some unit tests:

<?php declare(strict_types=1);

// tests/Type/Post59UnitTest.php

namespace App\Tests\Type;

use App\Type\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);
    }
}

These unit tests are basic. We create a form instance and then we submit some values. We check that no error was raised during the build process (isSynchronized()) and that all our widgets are available for the view (email, honeypot and the CSRF token). I didn't know until writing this article but we can't test the form validation correctly in a unit test. That's not a problem because we will do it in the following functional tests:

<?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());
    }
}

In the functional tests, we use a dataProvider. This allows us to avoid writing loops. The data provider will be responsible to pass to our tests the values to use and the expected result. We submit the form with these parameters and then we test the response status and if the message was found in the response. I use the yield function for clarity and to save two lines (return+[]). Let's run this:

[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)

Victory! All is working as expected. 🎉 😀

Conclusion and bits of advice

This post is now quite big with the code examples. But of course, if you have a registration or newsletter form you probably already have most of this. You can give a try by adding the honeypot and switching the names of the fields as described in the first part. Since I have implemented this honeypot on one of my side projects I don't have spam anymore on the newsletter form. I also made other tests, for example, to change the CRSF field name but it doesn't help as generally, the bots will keep the default values they will find in the form (except for radio or select inputs where they will pick a random value).

Some other advice I can give you about emails. If you have a registration form and you send a "Confirm my email" with a link, never use in this email information from the user. For example, don't write: "Hello Fab Pot, please confirm your account by clicking on this link". If you do so, hackers could use your form to send spam as they could replace the names by URLs.
For a newsletter form, if a user subscribes, never send to the email the last one you have. In the same way, hackers could use your form with emails of real people to spam them and to get their mailbox full, so they can't see an important email like "The password of your service xxx hax been modified". That's called mail-flooding.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web  More on Stackoverflow

They gave feedback and helped me to fix errors and typos in this article, many thanks to jmsche, keversc. 😊


» Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

  • Report any error/typo.
  • Report something that could be improved.
  • Like and retweet!
  • Follow me on Twitter
  • Subscribe to the RSS feed.

Thank you for reading! And see you soon on Strangebuzz! 😉

COil