Drupal 9: Integrating Flood Protection Into Forms

13th December 2020 - 10 minutes read time

Drupal's login forms are protected by a protection mechanism that prevents brute force attacks. This means that if an attacker attempts to repeatedly guess a user's password to gain entry to their account they will be blocked before being successful. This system has been a part of Drupal for many years and so is battle tested.

Like all systems in Drupal, the flood system can be adapted to be used on your own forms. Which means you can protect any form that you don't want to be used too much. This will help with authentication forms or any form that might need to process lots of information where you don't want users to submit the form too much.

Before using the flood system on a form you first need to inject it into the form. Here is a basic form setup with the flood service injected into it.

<?php

namespace Drupal\mymodule\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Flood\FloodInterface;

class FloodProtectedForm extends FormBase
{

  /**
   * {@inheritdoc}
   */
  public function getFormId()
  {
    return 'mymodule_flood_protected_form';
  }

  /**
   * The flood service.
   *
   * @var \Drupal\Core\Flood\FloodInterface
   */
  protected $flood;

  /**
   * FloodProtectedForm constructor.
   *
   * @param \Drupal\Core\Flood\FloodInterface $flood
   *   The flood service.
   */
  public function __construct(FloodInterface $flood)
  {
    $this->flood = $flood;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container)
  {
    return new static(
      $container->get('flood')
    );
  }

  // rest of the form definition goes here.
}

From here there are a couple of methods on the flood service that come in useful when we need to protect the form. The first method is register(), which is used to register the form with the flood service. This method takes three arguments.

  • name - Each flood integration needs an event to distinguish is from other events. It is recommended to use the module name followed by a dot and the actual event name (e.g. "mymodule.some_flood_event") to prevent unintended name clashes.
  • window - This is the number of seconds before this event expires. Expired events are automatically purged on a cron run to prevent the flood table growing indefinitely. The default is 3600 or 1 hour.
  • identifier - The identifier for the user using the form. This defaults to the current IP address, but is normally extended to include some other information. This can be the username or some information that was submitted into the form. This also can't be longer than 128 characters as it will cause an error when attempting to write the flood record to the database. The default for this is null, which means that every user who submits the form will be registered against the same event.

The register() method is normally called in the submit or validation handler. It essentially tells Drupal that the user has used the form.

$this->flood->register('mymodule.some_flood_event', 3600, $floodIdentifier);

With that in place the next step is to add a check to the validation handler to ensure that the user isn't submitting the form too many times. This is accomplished by the isAllowed() method of the flood service. This.

  • name - This is the event name, which is the same name as you stipulated when registering the form with the flood service.
  • threshold - The threshold is the number of times that the user cam submit the form before they are considered to be spamming the form. The default in use on the user login form is 5, but you can set it higher than this to allow users to fill in the form more times.
  • window - The window is the number of seconds in the time window for the event. Users can only do the event a certain number of times during this window. The default here is 3600 seconds or 1 hour.
  • identifier - The default for this argument is null, which means that all users will be checked against the event.

The isAllowed() method returns a boolean value. If the user is fine then true is returned, but if they manage to trigger the event then it will return false. This is normally called in the validation handler and should trigger a form error if the user is not allowed to continue.

if (!$this->flood->isAllowed('mymodule.some_flood_event', 10, 3600, $floodIdentifier)) {
  $form_state->setErrorByName('form_element', $this->t('Too many uses of this form from your IP address. This IP address is temporarily blocked. Try again later.'));
  return;
}

Through the normal operation the user will tend towards not using the form more than they need to, you can optionally clear their flood status using the clear() method. This method takes the name of the event and an optional identifier. Calling this method on the flood service will reset this flood registration in the system. This is used within Drupal by the authentication system so that if the user does successfully manage to log in then the flood registration is cleared for that user. If, however, you are using flood to protect against a form that has heavy processing and you are just protecting the form then you'll probably not need to clear the flood system.

$this->flood->clear('mymodule.some_flood_event', $floodIdentifier);

Let's put this into action by extending the FloodProtectedForm class that we started at the top. This class already has the flood protection included as a dependency so we just need to fill in the rest of the form.

Starting with the form itself, we just need a simple text field and a submit button to show this working.

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state)
  {
    $form['text'] = [
      '#type' => 'textfield',
      '#title' => 'Flood protected field',
      '#required' => TRUE,
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Test',
    ];

    return $form;
  }

In the validation handler we want to check to see if the user has violated the flood protection rules. This is a simple check, but the most complex part of this is deciding on what should be your flood control identifier. If this is a user form then we can use the user as part of the identification of the flood check, but if this is a form intended to be used by anonymous users then you have to be a bit more creative with the identifier. By default, the flood system uses the IP address of the user submitting the form, which for most requests might be enough. If, however, your needs are a bit more complex then you might inject some of the submitted form into the identifier.

In the below example of a validation handler I have taken the require form element that the use has entered and included this as a hashed value along with the IP address of the user. A hash is created of this value to ensure that the value doesn't exceed 128 characters. If the user triggers this then we inform them of the problem and return the validation function to prevent any further actions happening. The isAllowed() method here is setup in a way that will trigger if the user submits the same information into the form more than 10 times in an hour.

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state)
  {
    $text = $form_state->getValue('text');
    $hashedValue = Crypt::hashBase64($text);
    $floodIdentifier = $this->getRequest()->getClientIP() . $hashedValue;

    if (!$this->flood->isAllowed('mymodule.flood_protected_form', 10, 3600, $floodIdentifier)) {
      $form_state->setErrorByName('text', $this->t('Too many uses of this form from your IP address. This IP address is temporarily blocked. Try again later.'));
      return;
    }
  }

Finally, the submission handler of the form needs to register the user's submission with the flood system. This uses the same hash and IP address identifier as the validation method has and uses this information to register the from submission with the flood service. As the ID for the flood system needs to be the same it might be useful to abstract into a single method.

The example below shows the register method being used on the form submission with the flood window set to be 1 hour. This form is not doing anything else with the submission and is just an example used to demonstrate the action of the flood service.

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state)
  {
    $text = $form_state->getValue('text');
    $hashedValue = Crypt::hashBase64($text);
    $floodIdentifier = $this->getRequest()->getClientIP() . $hashedValue;

    $this->flood->register('mymodule.flood_protected_form', 3600, $floodIdentifier);
  }

With all of these elements in place the form will protected against users submitting the same data more than 10 times in an hour. You can use this the flood service in conjunction with a cache to have a really robust system that will precent any excess strain being put on your Drupal site. 

If you want to see the class in full then I have created a gist that will allow you to copy the FloodProtectedForm class and adapt it to your own needs.

Add new comment

The content of this field is kept private and will not be shown publicly.