Securing Front-End User Profile and Entry Forms in Craft

May 4, 2021
by Ben Croker

Every Craft site that allows public registration implicitly grants its users elevated privileges. Specifically, users have the ability to update all custom fields on their profile, as well as in any entries that they have permission to edit. If you use custom fields for private” admin use only, then you may be leaving your site open to abuse. 

Securing frontend forms in craft

Craft CMS embraces flexibility by allowing you to assign as many custom fields as you like to users, entries and most other element types. Craft also provides useful controller actions for you to update users and entries through front-end forms. If you run any sites in which public registration is enabled, then you will likely be using front-end user accounts including a user profile form and possibly also entry forms.

Since Craft does not, at the time of writing, have any sort of field-level permissions, logged-in users can modify all custom fields on their profile. This is intentional, as it is, after all, their profile. If, however, you use any custom fields for admin use only, then you may be unwillingly leaving sensitive data open to abuse. 

One of the golden rules of security is to never trust user input.

Let’s say we have a site in which users can update their profiles on the front-end with basic data such as first and last name, email address and a bio. We also want site administrators to be able to make a private note about each user, that is only accessible in the control panel. So we create a plaintext field called adminNote and assign it to user profiles. We never expose the value of this field on the front-end, nor do we include it in the user profile form, which looks like this when rendered.

<form method="post">
  <input type="hidden" name="CRAFT_CSRF_TOKEN" value="wuqSIkWkvN4e71b0r6uUvIjVvNK8vgflE9eB7dFskXRNoZolRMnYUKltc7tARw1_0YMuhshPGehFekIjBukxxoiPMs4m8La7A_Zj5a4xRWFW4fzna5zYwjBMBuIvWBJNfUi0cPKZ2V6Dgtzo57AH5WacuCKAeL4kMTmub3EAj8Zg2VGt-ByR2Nyny-C56BiU1aO_GtE65cMI3fFzXZECMDGRnAfXiXOZ4heM2efNAK_QYOs2qLna9GJ51bNtME5xjk84fQhmbN-A48eDf_jAl5DS-mgR0P7uZL8QgODY7I7thYyh2vxNtlKfybvlXN8yDv7eFRyQnGPVXkTdcSI8SuCzGbH8KynYckl7RmPZBKC5uVH3EsiOiDrBW9PLV3EFNYXMgQ-u7vtRemeHHm8hf0Qu0UCQ-ug7sdDkgK3kc6dW5uhk9DfNXANc_l8CZs2MM5gZ5a4ooZaa5JSkibBB0Obfjmb1CJznOe7VFjjCNh546NpztdM-qoVluayxiWzEnxeeRueXmLItEKHGKlgNPekBfydfIhuatpqjsSmUr6U=">
  <input type="hidden" name="action" value="users/save-user">
  <input type="hidden" name="userId" value="13">

  First Name: <input type="text" name="firstName" value="Art">
  Last Name: <input type="text" name="lastName" value="Garfunkel">
  Email: <input type="email" name="email" value="[email protected]">
  Bio: <textarea name="fields[bio]">One top 10 hit, three top 20 hits, six top 40 hits.</textarea>

  <input type="submit" value="Save Profile">
</form>

All appears well, however, note that the users/save-user controller action, to which this form submits, does not discriminate against custom fields that are submitted, regardless of whether or not we included them in the form. So if a malicious user were to open their browser developer tools and inject an input field called adminNote into the form and submit it, its value would indeed be modified.

<input type="hidden" name="fields[adminNote]" value="Send free stuff">

The same goes for all fields on entries that users have permission to edit, using the entries/save-entry controller action.

The example above might seem harmless, but there may be cases in which you are storing important information on a user that you absolutely do not want them to be able to modify. You may, for example, want to store subscription data for a service on each user. If the user was aware of this, they could try to guess the field handles and manipulate their subscription status.

<input type="hidden" name="fields[subscriptionStatus]" value="paid">
<input type="hidden" name="fields[subscriptionExpiry]" value="2099-12-31">

You may be tempted to give these private” fields handles that are hard to guess, such as subscriptionStatusPrivate999, but security through obscurity should never be relied on for data integrity. Also, a site that has GraphQL enabled may reveal custom field handles to all users who know how to access the public API. Instead, you should lock these fields down by forbidding them from being writable from the front-end.

This issue also affects custom fields that are modified through GraphQL mutations.

Field-level permissions are, according to this issue, planned for Craft 4. Until then, you can implement this yourself with a custom module and a few lines of code.

class FieldPermissions extends Module
{
  const DISALLOWED_CUSTOM_FIELDS = ['subscriptionStatus', 'subscriptionExpiry'];

  public function init()
  {
    Event::on(UsersController::class, UsersController::EVENT_BEFORE_ACTION,
      function(ActionEvent $event) {
        if ($event->action->id == 'save-user' && Craft::$app->request->isSiteRequest) {
          $fieldsLocation = Craft::$app->request->getBodyParam('fieldsLocation', 'fields');
          $fields = Craft::$app->request->getBodyParam($fieldsLocation, []);

          foreach ($fields as $key => $value) {
            // Throw an exception if the field is disallowed.
            if (in_array($key, self::DISALLOWED_CUSTOM_FIELDS)) {
              throw new ForbiddenHttpException('One or more disallowed fields were submitted.');
            }
          }
        }
      }
    );
  }
}

In the module above, we specify an array of disallowed field handles. This makes sense if we have a few fields that we want to keep private. Any new custom fields that are assigned to user profiles will, by default, be allowed fields. If, on the other hand, we wanted to specify which fields are allowed and make it so that all new custom fields are disallowed, we could reverse the logical operator on the condition.

class FieldPermissions extends Module
{
  const ALLOWED_CUSTOM_FIELDS = ['bio'];

  public function init()
  {
    Event::on(UsersController::class, UsersController::EVENT_BEFORE_ACTION,
      function(ActionEvent $event) {
        if ($event->action->id == 'save-user' && Craft::$app->request->isSiteRequest) {
          $fieldsLocation = Craft::$app->request->getBodyParam('fieldsLocation', 'fields');
          $fields = Craft::$app->request->getBodyParam($fieldsLocation, []);

          foreach ($fields as $key => $value) {
            // Throw an exception if the field is not allowed.
            if (!in_array($key, self::ALLOWED_CUSTOM_FIELDS)) {
              throw new ForbiddenHttpException('One or more disallowed fields were submitted.');
            }
          }
        }
      }
    );
  }
}

The module listens for the EVENT_BEFORE_ACTION event to be triggered on the UsersController class. By targeting this event, we ensure that we catch every submission to the users/save-user controller action. We check whether each of the fields submitted in the posted fields parameter (which can be renamed using the fieldsLocation param in Craft 3.6.13 and above) is allowed (or disallowed) only if this is a (front-end) site request. This assumes that users that have permission to access the control panel are allowed to modify all fields. 

Finally, if one or more submitted fields is determined to be disallowed, we throw an exception. This will block the controller action from proceeding, assuming that the user has done something malicious. We could, alternatively, filter out disallowed fields, thereby not revealing to the user that they submitted a disallowed field, but throwing an exception feels appropriate in this case.

The same can be achieved for entry forms by switching UsersController with EntriesController and save-user with save-entry.

Note that while the Field and Tab Permissions plugin allows you to restrict the visibility of fields and tabs to particular user groups per site”, it does not mean that fields can be locked down from being updated by users.

In conclusion, if you assumed that simply excluding custom fields from your user profile and entry forms was enough to protect them, then hopefully you have learned that it is not, but that with a few lines of code you can lock them down and ensure data integrity. See the full source code, which includes logic to deal with important differences in Craft 3.6.13 and above/​below.