Validating Input

Request parameters are untrusted strings; the form key is Magento's CSRF token, and a mass action must filter through the grid.

Syntax$this->formKeyValidator->validate($this->getRequest()): bool

A request parameter is a string that arrived from a browser. $this->getRequest()->getParam('points') returns whatever was sent: an array where you expected a number, an empty string, or null. Cast it and check its range in the controller, before it reaches anything that writes.

The form key

Magento's CSRF (cross-site request forgery) token is called the form key. Magento\Framework\App\Request\CsrfValidator runs before every storefront action and rejects a POST unless the form_key parameter matches the session value. In the admin area, Magento\Backend\App\Request\BackendValidator checks the form key on POST, and on GET checks the secret key Magento appends to admin URLs while Stores > Configuration > Advanced > Admin > Security > Add Secret Key to URLs is Yes.

One exception matters. The storefront validator treats the request as valid when $request->isXmlHttpRequest() is true, so an AJAX POST endpoint is not covered for you. Inject Magento\Framework\Data\Form\FormKey\Validator and call validate($request) yourself.

Mass actions

An admin grid mass action posts selected or excluded ids. Reading selected and deleting by it deletes whatever ids the request names, regardless of what the operator could see. Pass your collection through Magento\Ui\Component\MassAction\Filter::getCollection(), which re-applies the grid's own filters and selection.

A new controller is a new class: run bin/magento cache:flush, and in production mode bin/magento setup:di:compile.

Example

Example · php
<?php
// app/code/Vendor/Loyalty/Controller/Points/Redeem.php
declare(strict_types=1);

namespace Vendor\Loyalty\Controller\Points;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Data\Form\FormKey\Validator as FormKeyValidator;

class Redeem implements HttpPostActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $resultJsonFactory,
        private readonly FormKeyValidator $formKeyValidator
    ) {
    }

    public function execute(): Json
    {
        $result = $this->resultJsonFactory->create();

        if (!$this->formKeyValidator->validate($this->request)) {
            return $result->setHttpResponseCode(403)
                ->setData(['message' => __('Invalid form key. Please refresh the page.')]);
        }

        $points = (int) $this->request->getParam('points');

        if ($points < 1 || $points > 10000) {
            return $result->setHttpResponseCode(400)
                ->setData(['message' => __('Enter between 1 and 10000 points.')]);
        }

        return $result->setData(['redeemed' => $points]);
    }
}

When to use it

  • Harden an AJAX POST endpoint that the default CSRF validator skips because the request is XHR.
  • Accept a signed webhook without switching CSRF protection off for the whole controller.
  • Make a grid mass action operate on the grid's own selection instead of ids from the request body.
  • Return a native-looking error when a quantity arrives as text or as an array.

More examples

A mass action that cannot be told which ids to delete

The reader sees a redirect back to the grid with "A total of 3 record(s) have been deleted." The ids never come from the request in your code: Filter::getCollection() reads selected and excluded and re-applies the grid's filters, so a forged selected[] value cannot reach rows outside that grid. Reach for this shape for every grid mass action, and note the shorthand in the constructor - a promoted readonly property is the same thing as declaring private readonly Filter $filter and assigning $this->filter = $filter in the body.

Example · php
<?php
// app/code/Vendor/Loyalty/Controller/Adminhtml/Points/MassDelete.php
declare(strict_types=1);

namespace Vendor\Loyalty\Controller\Adminhtml\Points;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\View\Result\Redirect;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Ui\Component\MassAction\Filter;
use Vendor\Loyalty\Model\ResourceModel\Ledger\CollectionFactory;

class MassDelete extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'Vendor_Loyalty::points_adjust';

    public function __construct(
        Context $context,
        private readonly Filter $filter,
        private readonly CollectionFactory $collectionFactory
    ) {
        parent::__construct($context);
    }

    public function execute(): ResultInterface
    {
        $collection = $this->filter->getCollection($this->collectionFactory->create());
        $deleted = $collection->getSize();

        foreach ($collection as $entry) {
            $entry->delete();
        }

        $this->messageManager->addSuccessMessage(__('A total of %1 record(s) have been deleted.', $deleted));

        /** @var Redirect $redirect */
        $redirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);

        return $redirect->setPath('*/*/');
    }
}

A webhook that validates something real instead of nothing

CsrfAwareActionInterface declares validateForCsrf(RequestInterface $request): ?bool and createCsrfValidationException(RequestInterface $request): ?InvalidRequestException. Returning null from either means "use the default". Returning true from validateForCsrf disables the check; returning the result of a signature comparison replaces it with one the caller must actually pass. The reader sees a 200 with an empty body on a signed request and the default "Invalid Form Key" redirect on an unsigned one.

Example · php
<?php
// app/code/Vendor/Loyalty/Controller/Webhook/Points.php
declare(strict_types=1);

namespace Vendor\Loyalty\Controller\Webhook;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Raw;
use Magento\Framework\Controller\Result\RawFactory;

class Points implements CsrfAwareActionInterface, HttpPostActionInterface
{
    public function __construct(
        private readonly RawFactory $resultRawFactory,
        private readonly \Vendor\Loyalty\Model\WebhookSignature $signature
    ) {
    }

    public function validateForCsrf(RequestInterface $request): ?bool
    {
        // Not a browser form, so there is no form key. Verify the
        // provider's HMAC header instead of returning true blindly.
        return $this->signature->isValid(
            (string) $request->getHeader('X-Provider-Signature'),
            (string) $request->getContent()
        );
    }

    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    public function execute(): Raw
    {
        return $this->resultRawFactory->create()->setContents('');
    }
}

Rejecting a bad value with a typed exception

InputException::requiredField($fieldName) and InputException::invalidFieldValue($fieldName, $fieldValue) produce the same wording the rest of Magento uses, so the message looks native in a form error and in a web API response. Pull validation into a small class like this once two controllers need the same rule, and keep the casting there rather than scattered through execute().

Example · php
<?php
// app/code/Vendor/Loyalty/Model/PointRequestValidator.php
declare(strict_types=1);

namespace Vendor\Loyalty\Model;

use Magento\Framework\Exception\InputException;

class PointRequestValidator
{
    private const MAX_POINTS = 10000;

    /**
     * @throws InputException
     */
    public function validatePoints(mixed $raw): int
    {
        if ($raw === null || $raw === '') {
            throw InputException::requiredField('points');
        }

        if (!is_numeric($raw) || (int) $raw < 1 || (int) $raw > self::MAX_POINTS) {
            throw InputException::invalidFieldValue('points', is_scalar($raw) ? (string) $raw : 'array');
        }

        return (int) $raw;
    }
}

// Output:
// validatePoints('250')  => 250
// validatePoints('abc')  => InputException: Invalid value of "abc" provided for the points field.
// validatePoints(null)   => InputException: "points" is required. Enter and try again.

Discussion

  • Be the first to comment on this lesson.