Browse Source

Merge pull request #13728 from cakephp/4.x-form-protector

4.x form protector
Mark Story 6 years ago
parent
commit
0f9df08b77

+ 164 - 0
src/Controller/Component/FormProtectionComponent.php

@@ -0,0 +1,164 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Controller\Component;
+
+use Cake\Controller\Component;
+use Cake\Core\Configure;
+use Cake\Event\EventInterface;
+use Cake\Form\FormProtector;
+use Cake\Http\Exception\BadRequestException;
+use Cake\Http\Response;
+use Closure;
+
+/**
+ * Protects against form tampering. It ensures that:
+ *
+ * - Form's action (URL) is not modified.
+ * - Unknown / extra fields are not added to the form.
+ * - Existing fields have not been removed from the form.
+ * - Values of hidden inputs have not been changed.
+ *
+ * @psalm-property array{validatePost:bool, unlockedFields:array, unlockedActions:array, validationFailureCallback:?\Closure} $_config
+ */
+class FormProtectionComponent extends Component
+{
+    /**
+     * Default message used for exceptions thrown.
+     */
+    public const DEFAULT_EXCEPTION_MESSAGE = 'Form tampering protection token validation failed.';
+
+    /**
+     * Default config
+     *
+     * - `validate` - Whether to validate request body / data. Set to false to disable
+     *   for data coming from 3rd party services, etc.
+     * - `unlockedFields` - Form fields to exclude from validation. Fields can
+     *   be unlocked either in the Component, or with FormHelper::unlockField().
+     *   Fields that have been unlocked are not required to be part of the POST
+     *   and hidden unlocked fields do not have their values checked.
+     * - `unlockedActions` - Actions to exclude from POST validation checks.
+     * - `validationFailureCallback` - Callback to call in case of validation
+     *   failure. Must be a valid Closure. Unset by default in which case
+     *   exception is thrown on validation failure.
+     *
+     * @var array
+     */
+    protected $_defaultConfig = [
+        'validate' => true,
+        'unlockedFields' => [],
+        'unlockedActions' => [],
+        'validationFailureCallback' => null,
+    ];
+
+    /**
+     * Component startup.
+     *
+     * Token check happens here.
+     *
+     * @param \Cake\Event\EventInterface $event An Event instance
+     * @return \Cake\Http\Response|null
+     */
+    public function startup(EventInterface $event): ?Response
+    {
+        $request = $this->getController()->getRequest();
+        $data = $request->getParsedBody();
+        $hasData = ($data || $request->is(['put', 'post', 'delete', 'patch']));
+
+        if (
+            !in_array($request->getParam('action'), $this->_config['unlockedActions'], true)
+            && $hasData
+            && $this->_config['validate']
+        ) {
+            $formProtector = new FormProtector();
+            $request->getSession()->start();
+            $isValid = $formProtector->validate(
+                $data,
+                $request->getRequestTarget(),
+                $request->getSession()->id()
+            );
+
+            if (!$isValid) {
+                return $this->validationFailure($formProtector);
+            }
+        }
+
+        $token = [
+            'unlockedFields' => $this->_config['unlockedFields'],
+        ];
+        $request = $request->withAttribute('formTokenData', [
+            'unlockedFields' => $token['unlockedFields'],
+        ]);
+
+        if (is_array($data)) {
+            unset($data['_Token']);
+            $request = $request->withParsedBody($data);
+        }
+
+        $this->getController()->setRequest($request);
+
+        return null;
+    }
+
+    /**
+     * Events supported by this component.
+     *
+     * @return array
+     */
+    public function implementedEvents(): array
+    {
+        return [
+            'Controller.startup' => 'startup',
+        ];
+    }
+
+    /**
+     * Throws a 400 - Bad request exception or calls custom callback.
+     *
+     * If `validationFailureCallback` config is specified, it will use this
+     * callback by executing the method passing the argument as exception.
+     *
+     * @param \Cake\Form\FormProtector $formProtector Form Protector instance.
+     * @return \Cake\Http\Response|null If specified, validationFailureCallback's response, or no return otherwise.
+     * @throws \Cake\Http\Exception\BadRequestException
+     */
+    protected function validationFailure(FormProtector $formProtector): ?Response
+    {
+        if (Configure::read('debug')) {
+            $exception = new BadRequestException($formProtector->getError());
+        } else {
+            $exception = new BadRequestException(static::DEFAULT_EXCEPTION_MESSAGE);
+        }
+
+        if ($this->_config['validationFailureCallback']) {
+            return $this->executeCallback($this->_config['validationFailureCallback'], $exception);
+        }
+
+        throw $exception;
+    }
+
+    /**
+     * Execute callback.
+     *
+     * @param \Closure $callback A valid callable
+     * @param \Cake\Http\Exception\BadRequestException $exception Exception instance.
+     * @return \Cake\Http\Response|null
+     */
+    protected function executeCallback(Closure $callback, BadRequestException $exception): ?Response
+    {
+        return $callback($exception);
+    }
+}

+ 3 - 1
src/Controller/Component/SecurityComponent.php

@@ -37,6 +37,8 @@ use Cake\Utility\Security;
  * - Requiring that SSL be used.
  *
  * @link https://book.cakephp.org/3.0/en/controllers/components/security.html
+ * @deprecated 4.0.0 Use FormProtectionComponent instead, for form tampering protection
+ *   or HttpsEnforcerMiddleware to enforce use of HTTPS (SSL) for requests.
  */
 class SecurityComponent extends Component
 {
@@ -488,7 +490,7 @@ class SecurityComponent extends Component
             'unlockedFields' => $this->_config['unlockedFields'],
         ];
 
-        return $request->withAttribute('formToken', [
+        return $request->withAttribute('formTokenData', [
             'unlockedFields' => $token['unlockedFields'],
         ]);
     }

+ 607 - 0
src/Form/FormProtector.php

@@ -0,0 +1,607 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Form;
+
+use Cake\Core\Configure;
+use Cake\Utility\Hash;
+use Cake\Utility\Security;
+
+/**
+ * Protects against form tampering. It ensures that:
+ *
+ * - Form's action (URL) is not modified.
+ * - Unknown / extra fields are not added to the form.
+ * - Existing fields have not been removed from the form.
+ * - Values of hidden inputs have not been changed.
+ *
+ * @internal
+ */
+class FormProtector
+{
+    /**
+     * Fields list.
+     *
+     * @var array
+     */
+    protected $fields = [];
+
+    /**
+     * Unlocked fields.
+     *
+     * @var array
+     */
+    protected $unlockedFields = [];
+
+    /**
+     * Form URL
+     *
+     * @var string
+     */
+    protected $url = '';
+
+    /**
+     * Session Id
+     *
+     * @var string
+     */
+    protected $sessionId = '';
+
+    /**
+     * Error message providing detail for failed validation.
+     *
+     * @var string|null
+     */
+    protected $debugMessage;
+
+    /**
+     * Construct.
+     *
+     * @param string $url Form URL.
+     * @param string $sessionId Session Id.
+     * @param array $data Data array, can contain key `unlockedFields` with list of unlocked fields.
+     */
+    public function __construct(string $url = '', string $sessionId = '', array $data = [])
+    {
+        $this->url = $url;
+        $this->sessionId = $sessionId;
+
+        if (!empty($data['unlockedFields'])) {
+            $this->unlockedFields = $data['unlockedFields'];
+        }
+    }
+
+    /**
+     * Validate submitted form data.
+     *
+     * @param mixed $formData Form data.
+     * @param string $url URL form was POSTed to.
+     * @param string $sessionId Session id for hash generation.
+     * @return bool
+     */
+    public function validate($formData, string $url, string $sessionId): bool
+    {
+        $this->debugMessage = null;
+
+        $extractedToken = $this->extractToken($formData);
+        if (empty($extractedToken)) {
+            return false;
+        }
+
+        $hashParts = $this->extractHashParts($formData);
+        $generatedToken = $this->generateHash(
+            $hashParts['fields'],
+            $hashParts['unlockedFields'],
+            $url,
+            $sessionId
+        );
+
+        if (hash_equals($generatedToken, $extractedToken)) {
+            return true;
+        }
+
+        if (Configure::read('debug')) {
+            $debugMessage = $this->debugTokenNotMatching($formData, $hashParts + compact('url', 'sessionId'));
+            if ($debugMessage) {
+                $this->debugMessage = $debugMessage;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine which fields of a form should be used for hash.
+     *
+     * @param string|array $field Reference to field to be secured. Can be dot
+     *   separated string to indicate nesting or array of fieldname parts.
+     * @param bool $lock Whether this field should be part of the validation
+     *   or excluded as part of the unlockedFields. Default `true`.
+     * @param mixed $value Field value, if value should not be tampered with.
+     * @return $this
+     */
+    public function addField($field, bool $lock = true, $value = null)
+    {
+        if (is_string($field)) {
+            $field = $this->getFieldNameArray($field);
+        }
+
+        if (empty($field)) {
+            return $this;
+        }
+
+        foreach ($this->unlockedFields as $unlockField) {
+            $unlockParts = explode('.', $unlockField);
+            if (array_values(array_intersect($field, $unlockParts)) === $unlockParts) {
+                return $this;
+            }
+        }
+
+        $field = implode('.', $field);
+        $field = preg_replace('/(\.\d+)+$/', '', $field);
+
+        if ($lock) {
+            if (!in_array($field, $this->fields, true)) {
+                if ($value !== null) {
+                    $this->fields[$field] = $value;
+
+                    return $this;
+                }
+                if (isset($this->fields[$field])) {
+                    unset($this->fields[$field]);
+                }
+                $this->fields[] = $field;
+            }
+        } else {
+            $this->unlockField($field);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Parses the field name to create a dot separated name value for use in
+     * field hash. If filename is of form Model[field] or Model.field an array of
+     * fieldname parts like ['Model', 'field'] is returned.
+     *
+     * @param string $name The form inputs name attribute.
+     * @return string[] Array of field name params like ['Model.field'] or
+     *   ['Model', 'field'] for array fields or empty array if $name is empty.
+     */
+    protected function getFieldNameArray(string $name): array
+    {
+        if (empty($name) && $name !== '0') {
+            return [];
+        }
+
+        if (strpos($name, '[') === false) {
+            return Hash::filter(explode('.', $name));
+        }
+        $parts = explode('[', $name);
+        $parts = array_map(function ($el) {
+            return trim($el, ']');
+        }, $parts);
+
+        return Hash::filter($parts, 'strlen');
+    }
+
+    /**
+     * Add to the list of fields that are currently unlocked.
+     *
+     * Unlocked fields are not included in the field hash.
+     *
+     * @param string $name The dot separated name for the field.
+     * @return $this
+     */
+    public function unlockField($name)
+    {
+        if (!in_array($name, $this->unlockedFields, true)) {
+            $this->unlockedFields[] = $name;
+        }
+
+        $index = array_search($name, $this->fields, true);
+        if ($index !== false) {
+            unset($this->fields[$index]);
+        }
+        unset($this->fields[$name]);
+
+        return $this;
+    }
+
+    /**
+     * Get validation error message.
+     *
+     * @return string|null
+     */
+    public function getError(): ?string
+    {
+        return $this->debugMessage;
+    }
+
+    /**
+     * Extract token from data.
+     *
+     * @param mixed $formData Data to validate.
+     * @return string|null Fields token on success, null on failure.
+     */
+    protected function extractToken($formData): ?string
+    {
+        if (!is_array($formData)) {
+            $this->debugMessage = 'Request data is not an array.';
+
+            return null;
+        }
+
+        $message = '`%s` was not found in request data.';
+        if (!isset($formData['_Token'])) {
+            $this->debugMessage = sprintf($message, '_Token');
+
+            return null;
+        }
+        if (!isset($formData['_Token']['fields'])) {
+            $this->debugMessage = sprintf($message, '_Token.fields');
+
+            return null;
+        }
+        if (!isset($formData['_Token']['unlocked'])) {
+            $this->debugMessage = sprintf($message, '_Token.unlocked');
+
+            return null;
+        }
+        if (Configure::read('debug') && !isset($formData['_Token']['debug'])) {
+            $this->debugMessage = sprintf($message, '_Token.debug');
+
+            return null;
+        }
+        if (!Configure::read('debug') && isset($formData['_Token']['debug'])) {
+            $this->debugMessage = 'Unexpected `_Token.debug` found in request data';
+
+            return null;
+        }
+
+        $token = urldecode($formData['_Token']['fields']);
+        if (strpos($token, ':')) {
+            [$token, ] = explode(':', $token, 2);
+        }
+
+        return $token;
+    }
+
+    /**
+     * Return hash parts for the token generation
+     *
+     * @param array $formData Form data.
+     * @return array
+     * @psalm-return array{fields: array, unlockedFields: array}
+     */
+    protected function extractHashParts(array $formData): array
+    {
+        $fields = $this->extractFields($formData);
+        $unlockedFields = $this->sortedUnlockedFields($formData);
+
+        return [
+            'fields' => $fields,
+            'unlockedFields' => $unlockedFields,
+        ];
+    }
+
+    /**
+     * Return the fields list for the hash calculation
+     *
+     * @param array $formData Data array
+     * @return array
+     */
+    protected function extractFields(array $formData): array
+    {
+        $locked = '';
+        $token = urldecode($formData['_Token']['fields']);
+        $unlocked = urldecode($formData['_Token']['unlocked']);
+
+        if (strpos($token, ':')) {
+            [$token, $locked] = explode(':', $token, 2);
+        }
+        unset($formData['_Token']);
+
+        $locked = explode('|', $locked);
+        $unlocked = explode('|', $unlocked);
+
+        $fields = Hash::flatten($formData);
+        $fieldList = array_keys($fields);
+        $multi = $lockedFields = [];
+        $isUnlocked = false;
+
+        foreach ($fieldList as $i => $key) {
+            if (is_string($key) && preg_match('/(\.\d+){1,10}$/', $key)) {
+                $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key);
+                unset($fieldList[$i]);
+            } else {
+                $fieldList[$i] = (string)$key;
+            }
+        }
+        if (!empty($multi)) {
+            $fieldList += array_unique($multi);
+        }
+
+        $unlockedFields = array_unique(
+            array_merge(
+                $this->unlockedFields,
+                $unlocked
+            )
+        );
+
+        /** @var (string|int)[] $fieldList */
+        foreach ($fieldList as $i => $key) {
+            $isLocked = in_array($key, $locked, true);
+
+            if (!empty($unlockedFields)) {
+                foreach ($unlockedFields as $off) {
+                    $off = explode('.', $off);
+                    /** @psalm-suppress PossiblyInvalidArgument */
+                    $field = array_values(array_intersect(explode('.', $key), $off));
+                    $isUnlocked = ($field === $off);
+                    if ($isUnlocked) {
+                        break;
+                    }
+                }
+            }
+
+            if ($isUnlocked || $isLocked) {
+                unset($fieldList[$i]);
+                if ($isLocked) {
+                    $lockedFields[$key] = $fields[$key];
+                }
+            }
+        }
+        sort($fieldList, SORT_STRING);
+        ksort($lockedFields, SORT_STRING);
+        $fieldList += $lockedFields;
+
+        return $fieldList;
+    }
+
+    /**
+     * Get the sorted unlocked string
+     *
+     * @param array $formData Data array
+     * @return string[]
+     */
+    protected function sortedUnlockedFields(array $formData): array
+    {
+        $unlocked = urldecode($formData['_Token']['unlocked']);
+        if (empty($unlocked)) {
+            return [];
+        }
+
+        $unlocked = explode('|', $unlocked);
+        sort($unlocked, SORT_STRING);
+
+        return $unlocked;
+    }
+
+    /**
+     * Generate the token data.
+     *
+     * @return array The token data.
+     * @psalm-return array{fields: string, unlocked: string}
+     */
+    public function buildTokenData(): array
+    {
+        $fields = $this->fields;
+        $unlockedFields = $this->unlockedFields;
+
+        $locked = [];
+        foreach ($fields as $key => $value) {
+            if (is_numeric($value)) {
+                $value = (string)$value;
+            }
+            if (!is_int($key)) {
+                $locked[$key] = $value;
+                unset($fields[$key]);
+            }
+        }
+
+        sort($unlockedFields, SORT_STRING);
+        sort($fields, SORT_STRING);
+        ksort($locked, SORT_STRING);
+        $fields += $locked;
+
+        $fields = $this->generateHash($fields, $unlockedFields, $this->url, $this->sessionId);
+        $locked = implode('|', array_keys($locked));
+
+        return [
+            'fields' => urlencode($fields . ':' . $locked),
+            'unlocked' => urlencode(implode('|', $unlockedFields)),
+            'debug' => urlencode(json_encode([
+                $this->url,
+                $this->fields,
+                $this->unlockedFields,
+            ])),
+        ];
+    }
+
+    /**
+     * Generate validation hash.
+     *
+     * @param array $fields Fields list.
+     * @param array $unlockedFields Unlocked fields.
+     * @param string $url Form URL.
+     * @param string $sessionId Session Id.
+     * @return string
+     */
+    protected function generateHash(array $fields, array $unlockedFields, string $url, string $sessionId)
+    {
+        $hashParts = [
+            $url,
+            serialize($fields),
+            implode('|', $unlockedFields),
+            $sessionId,
+        ];
+
+        return hash_hmac('sha1', implode('', $hashParts), Security::getSalt());
+    }
+
+    /**
+     * Create a message for humans to understand why Security token is not matching
+     *
+     * @param array $formData Data.
+     * @param array $hashParts Elements used to generate the Token hash
+     * @return string Message explaining why the tokens are not matching
+     */
+    protected function debugTokenNotMatching(array $formData, array $hashParts): string
+    {
+        $messages = [];
+        if (!isset($formData['_Token']['debug'])) {
+            return 'Form protection debug token not found.';
+        }
+
+        $expectedParts = json_decode(urldecode($formData['_Token']['debug']), true);
+        if (!is_array($expectedParts) || count($expectedParts) !== 3) {
+            return 'Invalid form protection debug token.';
+        }
+        $expectedUrl = Hash::get($expectedParts, 0);
+        $url = Hash::get($hashParts, 'url');
+        if ($expectedUrl !== $url) {
+            $messages[] = sprintf('URL mismatch in POST data (expected `%s` but found `%s`)', $expectedUrl, $url);
+        }
+        $expectedFields = Hash::get($expectedParts, 1);
+        $dataFields = Hash::get($hashParts, 'fields') ?: [];
+        $fieldsMessages = $this->debugCheckFields(
+            (array)$dataFields,
+            $expectedFields,
+            'Unexpected field `%s` in POST data',
+            'Tampered field `%s` in POST data (expected value `%s` but found `%s`)',
+            'Missing field `%s` in POST data'
+        );
+        $expectedUnlockedFields = Hash::get($expectedParts, 2);
+        $dataUnlockedFields = Hash::get($hashParts, 'unlockedFields') ?: [];
+        $unlockFieldsMessages = $this->debugCheckFields(
+            (array)$dataUnlockedFields,
+            $expectedUnlockedFields,
+            'Unexpected unlocked field `%s` in POST data',
+            '',
+            'Missing unlocked field: `%s`'
+        );
+
+        $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages);
+
+        return implode(', ', $messages);
+    }
+
+    /**
+     * Iterates data array to check against expected
+     *
+     * @param array $dataFields Fields array, containing the POST data fields
+     * @param array $expectedFields Fields array, containing the expected fields we should have in POST
+     * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected)
+     * @param string $stringKeyMessage Message string if tampered found in
+     *  data fields indexed by string (protected).
+     * @param string $missingMessage Message string if missing field
+     * @return string[] Messages
+     */
+    protected function debugCheckFields(
+        array $dataFields,
+        array $expectedFields = [],
+        string $intKeyMessage = '',
+        string $stringKeyMessage = '',
+        string $missingMessage = ''
+    ): array {
+        $messages = $this->matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage);
+        $expectedFieldsMessage = $this->debugExpectedFields($expectedFields, $missingMessage);
+        if ($expectedFieldsMessage !== null) {
+            $messages[] = $expectedFieldsMessage;
+        }
+
+        return $messages;
+    }
+
+    /**
+     * Generate array of messages for the existing fields in POST data, matching dataFields in $expectedFields
+     * will be unset
+     *
+     * @param array $dataFields Fields array, containing the POST data fields
+     * @param array $expectedFields Fields array, containing the expected fields we should have in POST
+     * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected)
+     * @param string $stringKeyMessage Message string if tampered found in
+     *   data fields indexed by string (protected)
+     * @return string[] Error messages
+     */
+    protected function matchExistingFields(
+        array $dataFields,
+        array &$expectedFields,
+        string $intKeyMessage,
+        string $stringKeyMessage
+    ): array {
+        $messages = [];
+        foreach ($dataFields as $key => $value) {
+            if (is_int($key)) {
+                $foundKey = array_search($value, (array)$expectedFields, true);
+                if ($foundKey === false) {
+                    $messages[] = sprintf($intKeyMessage, $value);
+                } else {
+                    unset($expectedFields[$foundKey]);
+                }
+            } else {
+                if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
+                    $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
+                }
+                unset($expectedFields[$key]);
+            }
+        }
+
+        return $messages;
+    }
+
+    /**
+     * Generate debug message for the expected fields
+     *
+     * @param array $expectedFields Expected fields
+     * @param string $missingMessage Message template
+     * @return string|null Error message about expected fields
+     */
+    protected function debugExpectedFields(array $expectedFields = [], string $missingMessage = ''): ?string
+    {
+        if (count($expectedFields) === 0) {
+            return null;
+        }
+
+        $expectedFieldNames = [];
+        foreach ((array)$expectedFields as $key => $expectedField) {
+            if (is_int($key)) {
+                $expectedFieldNames[] = $expectedField;
+            } else {
+                $expectedFieldNames[] = $key;
+            }
+        }
+
+        return sprintf($missingMessage, implode(', ', $expectedFieldNames));
+    }
+
+    /**
+     * Return debug info
+     *
+     * @return array
+     */
+    public function __debugInfo(): array
+    {
+        return [
+            'url' => $this->url,
+            'sessionId' => $this->sessionId,
+            'fields' => $this->fields,
+            'unlockedFields' => $this->unlockedFields,
+            'debugMessage' => $this->debugMessage,
+        ];
+    }
+}

+ 8 - 4
src/TestSuite/IntegrationTestTrait.php

@@ -20,6 +20,7 @@ use Cake\Core\Configure;
 use Cake\Database\Exception as DatabaseException;
 use Cake\Error\ExceptionRenderer;
 use Cake\Event\EventInterface;
+use Cake\Form\FormProtector;
 use Cake\Http\Session;
 use Cake\Routing\Router;
 use Cake\TestSuite\Constraint\Response\BodyContains;
@@ -56,7 +57,6 @@ use Cake\Utility\CookieCryptTrait;
 use Cake\Utility\Hash;
 use Cake\Utility\Security;
 use Cake\Utility\Text;
-use Cake\View\Helper\SecureFieldTokenTrait;
 use Exception;
 use LogicException;
 use PHPUnit\Framework\Error\Error as PhpUnitError;
@@ -75,7 +75,6 @@ use Zend\Diactoros\Uri;
 trait IntegrationTestTrait
 {
     use CookieCryptTrait;
-    use SecureFieldTokenTrait;
 
     /**
      * The customized application class name.
@@ -661,10 +660,15 @@ trait IntegrationTestTrait
                 return preg_replace('/(\.\d+)+$/', '', $field);
             }, array_keys(Hash::flatten($fields)));
 
-            $tokenData = $this->_buildFieldToken($url, array_unique($keys), $this->_unlockedFields);
+            $formProtector = new FormProtector($url, 'cli', ['unlockedFields' => $this->_unlockedFields]);
+            foreach ($keys as $field) {
+                /** @psalm-suppress PossiblyNullArgument */
+                $formProtector->addField($field);
+            }
+            $tokenData = $formProtector->buildTokenData();
 
             $data['_Token'] = $tokenData;
-            $data['_Token']['debug'] = 'SecurityComponent debug data would be added here';
+            $data['_Token']['debug'] = 'FormProtector debug data would be added here';
         }
 
         if ($this->_csrfToken === true) {

+ 99 - 148
src/View/Helper/FormHelper.php

@@ -18,6 +18,7 @@ namespace Cake\View\Helper;
 
 use Cake\Core\Configure;
 use Cake\Core\Exception\Exception;
+use Cake\Form\FormProtector;
 use Cake\Routing\Router;
 use Cake\Utility\Hash;
 use Cake\Utility\Inflector;
@@ -46,7 +47,6 @@ use RuntimeException;
 class FormHelper extends Helper
 {
     use IdGeneratorTrait;
-    use SecureFieldTokenTrait;
     use StringTemplateTrait;
 
     /**
@@ -172,13 +172,6 @@ class FormHelper extends Helper
     ];
 
     /**
-     * List of fields created, used with secure forms.
-     *
-     * @var array
-     */
-    public $fields = [];
-
-    /**
      * Constant used internally to skip the securing process,
      * and neither add the field to the hash or to the unlocked fields.
      *
@@ -194,16 +187,6 @@ class FormHelper extends Helper
     public $requestType;
 
     /**
-     * An array of field names that have been excluded from
-     * the Token hash used by SecurityComponent's validatePost method
-     *
-     * @see \Cake\View\Helper\FormHelper::_secure()
-     * @see \Cake\Controller\Component\SecurityComponent::validatePost()
-     * @var string[]
-     */
-    protected $_unlockedFields = [];
-
-    /**
      * Locator for input widgets.
      *
      * @var \Cake\View\Widget\WidgetLocator
@@ -226,7 +209,7 @@ class FormHelper extends Helper
 
     /**
      * The action attribute value of the last created form.
-     * Used to make form/request specific hashes for SecurityComponent.
+     * Used to make form/request specific hashes for form tampering protection.
      *
      * @var string
      */
@@ -247,6 +230,13 @@ class FormHelper extends Helper
     protected $_groupedInputTypes = ['radio', 'multicheckbox'];
 
     /**
+     * Form protector
+     *
+     * @var \Cake\Form\FormProtector|null
+     */
+    protected $formProtector;
+
+    /**
      * Construct the widgets and binds the default context providers
      *
      * @param \Cake\View\View $view The View this helper is being attached to.
@@ -452,8 +442,12 @@ class FormHelper extends Helper
 
         $htmlAttributes += $options;
 
-        $this->fields = [];
         if ($this->requestType !== 'get') {
+            $formTokenData = $this->_View->getRequest()->getAttribute('formTokenData');
+            if ($formTokenData !== null) {
+                $this->formProtector = $this->createFormProtector($this->_lastAction, $formTokenData);
+            }
+
             $append .= $this->_csrfField();
         }
 
@@ -521,8 +515,7 @@ class FormHelper extends Helper
 
     /**
      * Return a CSRF input if the request data is present.
-     * Used to secure forms in conjunction with CsrfComponent &
-     * SecurityComponent
+     * Used to secure forms in conjunction with CsrfMiddleware.
      *
      * @return string
      */
@@ -530,13 +523,6 @@ class FormHelper extends Helper
     {
         $request = $this->_View->getRequest();
 
-        $formToken = $request->getAttribute('formToken');
-        if (!empty($formToken['unlockedFields'])) {
-            foreach ($formToken['unlockedFields'] as $unlocked) {
-                $this->_unlockedFields[] = $unlocked;
-            }
-        }
-
         $csrfToken = $request->getAttribute('csrfToken');
         if (!$csrfToken) {
             return '';
@@ -564,10 +550,8 @@ class FormHelper extends Helper
     {
         $out = '';
 
-        if ($this->requestType !== 'get' && $this->_View->getRequest()->getAttribute('formToken')) {
-            $out .= $this->secure($this->fields, $secureAttributes);
-            $this->fields = [];
-            $this->_unlockedFields = [];
+        if ($this->requestType !== 'get' && $this->_View->getRequest()->getAttribute('formTokenData') !== null) {
+            $out .= $this->secure([], $secureAttributes);
         }
         $out .= $this->formatTemplate('formEnd', []);
 
@@ -576,6 +560,7 @@ class FormHelper extends Helper
         $this->_context = null;
         $this->_valueSources = ['context'];
         $this->_idPrefix = $this->getConfig('idPrefix');
+        $this->formProtector = null;
 
         return $out;
     }
@@ -588,8 +573,8 @@ class FormHelper extends Helper
      * the hidden input tags generated for the Security Component. This is
      * especially useful to set HTML5 attributes like 'form'.
      *
-     * @param array $fields If set specifies the list of fields to use when
-     *    generating the hash, else $this->fields is being used.
+     * @param array $fields If set specifies the list of fields to be added to
+     *    FormProtector for generating the hash.
      * @param array $secureAttributes will be passed as HTML attributes into the hidden
      *    input elements generated for the Security Component.
      * @return string A hidden input field with a security hash, or empty string when
@@ -597,9 +582,18 @@ class FormHelper extends Helper
      */
     public function secure(array $fields = [], array $secureAttributes = []): string
     {
-        if (!$this->_View->getRequest()->getAttribute('formToken')) {
+        if (!$this->formProtector) {
             return '';
         }
+
+        foreach ($fields as $field => $value) {
+            if (is_int($field)) {
+                $field = $value;
+                $value = null;
+            }
+            $this->formProtector->addField($field, true, $value);
+        }
+
         $debugSecurity = Configure::read('debug');
         if (isset($secureAttributes['debugSecurity'])) {
             $debugSecurity = $debugSecurity && $secureAttributes['debugSecurity'];
@@ -608,11 +602,7 @@ class FormHelper extends Helper
         $secureAttributes['secure'] = static::SECURE_SKIP;
         $secureAttributes['autocomplete'] = 'off';
 
-        $tokenData = $this->_buildFieldToken(
-            $this->_lastAction,
-            $fields,
-            $this->_unlockedFields
-        );
+        $tokenData = $this->formProtector->buildTokenData();
         $tokenFields = array_merge($secureAttributes, [
             'value' => $tokenData['fields'],
         ]);
@@ -623,11 +613,7 @@ class FormHelper extends Helper
         $out .= $this->hidden('_Token.unlocked', $tokenUnlocked);
         if ($debugSecurity) {
             $tokenDebug = array_merge($secureAttributes, [
-                'value' => urlencode(json_encode([
-                    $this->_lastAction,
-                    $fields,
-                    $this->_unlockedFields,
-                ])),
+                'value' => $tokenData['debug'],
             ]);
             $out .= $this->hidden('_Token.debug', $tokenDebug);
         }
@@ -636,78 +622,52 @@ class FormHelper extends Helper
     }
 
     /**
-     * Add to or get the list of fields that are currently unlocked.
-     * Unlocked fields are not included in the field hash used by SecurityComponent
-     * unlocking a field once its been added to the list of secured fields will remove
-     * it from the list of fields.
+     * Add to the list of fields that are currently unlocked.
+     *
+     * Unlocked fields are not included in the form protection field hash.
      *
-     * @param string|null $name The dot separated name for the field.
-     * @return string[]|null Either null, or the list of fields.
-     * @link https://book.cakephp.org/3.0/en/views/helpers/form.html#working-with-securitycomponent
+     * @param string $name The dot separated name for the field.
+     * @return $this
      */
-    public function unlockField(?string $name = null): ?array
+    public function unlockField(string $name)
     {
-        if ($name === null) {
-            return $this->_unlockedFields;
-        }
-        if (!in_array($name, $this->_unlockedFields, true)) {
-            $this->_unlockedFields[] = $name;
-        }
-        $index = array_search($name, $this->fields, true);
-        if ($index !== false) {
-            unset($this->fields[$index]);
-        }
-        unset($this->fields[$name]);
+        $this->getFormProtector()->unlockField($name);
 
-        return null;
+        return $this;
     }
 
     /**
-     * Determine which fields of a form should be used for hash.
-     * Populates $this->fields
+     * Create FormProtector instance.
      *
-     * @param bool $lock Whether this field should be part of the validation
-     *   or excluded as part of the unlockedFields.
-     * @param string|array $field Reference to field to be secured. Can be dot
-     *   separated string to indicate nesting or array of fieldname parts.
-     * @param mixed $value Field value, if value should not be tampered with.
-     * @return void
+     * @param string $url URL
+     * @param array $formTokenData Token data.
+     * @return \Cake\Form\FormProtector
      */
-    protected function _secure(bool $lock, $field, $value = null): void
+    protected function createFormProtector(string $url, array $formTokenData): FormProtector
     {
-        if (empty($field) && $field !== '0') {
-            return;
-        }
+        $session = $this->_View->getRequest()->getSession();
+        $session->start();
 
-        if (is_string($field)) {
-            $field = Hash::filter(explode('.', $field));
-        }
+        return new FormProtector(
+            $url,
+            $session->id(),
+            $formTokenData
+        );
+    }
 
-        foreach ($this->_unlockedFields as $unlockField) {
-            $unlockParts = explode('.', $unlockField);
-            if (array_values(array_intersect($field, $unlockParts)) === $unlockParts) {
-                return;
-            }
+    /**
+     * Get form protector instance.
+     *
+     * @return \Cake\Form\FormProtector
+     * @throws \Cake\Core\Exception\Exception
+     */
+    public function getFormProtector(): FormProtector
+    {
+        if ($this->formProtector === null) {
+            throw new Exception('FormHelper::create() must be called first for FormProtector instance to be created.');
         }
 
-        $field = implode('.', $field);
-        $field = preg_replace('/(\.\d+)+$/', '', $field);
-
-        if ($lock) {
-            if (!in_array($field, $this->fields, true)) {
-                if ($value !== null) {
-                    $this->fields[$field] = $value;
-
-                    return;
-                }
-                if (isset($this->fields[$field])) {
-                    unset($this->fields[$field]);
-                }
-                $this->fields[] = $field;
-            }
-        } else {
-            $this->unlockField($field);
-        }
+        return $this->formProtector;
     }
 
     /**
@@ -1689,8 +1649,12 @@ class FormHelper extends Helper
             ['secure' => static::SECURE_SKIP]
         ));
 
-        if ($secure === true) {
-            $this->_secure(true, $this->_secureFieldName($options['name']), (string)$options['val']);
+        if ($secure === true && $this->formProtector) {
+            $this->formProtector->addField(
+                $options['name'],
+                true,
+                (string)$options['val']
+            );
         }
 
         $options['type'] = 'hidden';
@@ -1857,6 +1821,7 @@ class FormHelper extends Helper
 
         $restoreAction = $this->_lastAction;
         $this->_lastAction($url);
+        $restoreFormProtector = $this->formProtector;
 
         $action = $templater->formatAttributes([
             'action' => $this->Url->build($url),
@@ -1872,6 +1837,11 @@ class FormHelper extends Helper
         ]);
         $out .= $this->_csrfField();
 
+        $formTokenData = $this->_View->getRequest()->getAttribute('formTokenData');
+        if ($formTokenData !== null) {
+            $this->formProtector = $this->createFormProtector($this->_lastAction, $formTokenData);
+        }
+
         $fields = [];
         if (isset($options['data']) && is_array($options['data'])) {
             foreach (Hash::flatten($options['data']) as $key => $value) {
@@ -1882,7 +1852,9 @@ class FormHelper extends Helper
         }
         $out .= $this->secure($fields);
         $out .= $this->formatTemplate('formEnd', []);
+
         $this->_lastAction = $restoreAction;
+        $this->formProtector = $restoreFormProtector;
 
         if ($options['block']) {
             if ($options['block'] === true) {
@@ -1944,8 +1916,11 @@ class FormHelper extends Helper
             'templateVars' => [],
         ];
 
-        if (isset($options['name'])) {
-            $this->_secure($options['secure'], $this->_secureFieldName($options['name']));
+        if (isset($options['name']) && $this->formProtector) {
+            $this->formProtector->addField(
+                $options['name'],
+                $options['secure']
+            );
         }
         unset($options['secure']);
 
@@ -1956,17 +1931,20 @@ class FormHelper extends Helper
         unset($options['type']);
 
         if ($isUrl || $isImage) {
-            $unlockFields = ['x', 'y'];
-            if (isset($options['name'])) {
-                $unlockFields = [
-                    $options['name'] . '_x',
-                    $options['name'] . '_y',
-                ];
-            }
-            foreach ($unlockFields as $ignore) {
-                $this->unlockField($ignore);
-            }
             $type = 'image';
+
+            if ($this->formProtector) {
+                $unlockFields = ['x', 'y'];
+                if (isset($options['name'])) {
+                    $unlockFields = [
+                        $options['name'] . '_x',
+                        $options['name'] . '_y',
+                    ];
+                }
+                foreach ($unlockFields as $ignore) {
+                    $this->unlockField($ignore);
+                }
+            }
         }
 
         if ($isUrl) {
@@ -2295,7 +2273,7 @@ class FormHelper extends Helper
     protected function _initInputField(string $field, array $options = []): array
     {
         if (!isset($options['secure'])) {
-            $options['secure'] = (bool)$this->_View->getRequest()->getAttribute('formToken');
+            $options['secure'] = $this->_View->getRequest()->getAttribute('formTokenData') === null ? false : true;
         }
         $context = $this->_getContext();
 
@@ -2390,34 +2368,6 @@ class FormHelper extends Helper
     }
 
     /**
-     * Get the field name for use with _secure().
-     *
-     * Parses the name attribute to create a dot separated name value for use
-     * in secured field hash. If filename is of form Model[field] an array of
-     * fieldname parts like ['Model', 'field'] is returned.
-     *
-     * @param string $name The form inputs name attribute.
-     * @return string[] Array of field name params like ['Model.field'] or
-     *   ['Model', 'field'] for array fields or empty array if $name is empty.
-     */
-    protected function _secureFieldName(string $name): array
-    {
-        if (empty($name) && $name !== '0') {
-            return [];
-        }
-
-        if (strpos($name, '[') === false) {
-            return [$name];
-        }
-        $parts = explode('[', $name);
-        $parts = array_map(function ($el) {
-            return trim($el, ']');
-        }, $parts);
-
-        return array_filter($parts, 'strlen');
-    }
-
-    /**
      * Add a new context type.
      *
      * Form context types allow FormHelper to interact with
@@ -2513,12 +2463,13 @@ class FormHelper extends Helper
         $widget = $this->_locator->get($name);
         $out = $widget->render($data, $this->context());
         if (
+            $this->formProtector !== null &&
             isset($data['name']) &&
             $secure !== null &&
             $secure !== self::SECURE_SKIP
         ) {
             foreach ($widget->secureFields($data) as $field) {
-                $this->_secure($secure, $this->_secureFieldName($field));
+                $this->formProtector->addField($field, $secure);
             }
         }
 

+ 0 - 71
src/View/Helper/SecureFieldTokenTrait.php

@@ -1,71 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.1.2
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\View\Helper;
-
-use Cake\Utility\Security;
-
-/**
- * Provides methods for building token data that is
- * compatible with SecurityComponent.
- */
-trait SecureFieldTokenTrait
-{
-    /**
-     * Generate the token data for the provided inputs.
-     *
-     * @param string $url The URL the form is being submitted to.
-     * @param array $fields If set specifies the list of fields to use when
-     *    generating the hash.
-     * @param string[] $unlockedFields The list of fields that are excluded from
-     *    field validation.
-     * @return array The token data.
-     * @psalm-return array{fields: string, unlocked: string}
-     */
-    protected function _buildFieldToken(string $url, array $fields, array $unlockedFields = []): array
-    {
-        $locked = [];
-        foreach ($fields as $key => $value) {
-            if (is_numeric($value)) {
-                $value = (string)$value;
-            }
-            if (!is_int($key)) {
-                $locked[$key] = $value;
-                unset($fields[$key]);
-            }
-        }
-
-        sort($unlockedFields, SORT_STRING);
-        sort($fields, SORT_STRING);
-        ksort($locked, SORT_STRING);
-        $fields += $locked;
-
-        $locked = implode('|', array_keys($locked));
-        $unlocked = implode('|', $unlockedFields);
-        $hashParts = [
-            $url,
-            serialize($fields),
-            $unlocked,
-            session_id(),
-        ];
-        $fields = hash_hmac('sha1', implode('', $hashParts), Security::getSalt());
-
-        return [
-            'fields' => urlencode($fields . ':' . $locked),
-            'unlocked' => urlencode($unlocked),
-        ];
-    }
-}

+ 246 - 0
tests/TestCase/Controller/Component/FormProtectionComponentTest.php

@@ -0,0 +1,246 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Controller\Component;
+
+use Cake\Controller\Component\FormProtectionComponent;
+use Cake\Controller\Controller;
+use Cake\Event\Event;
+use Cake\Http\Exception\BadRequestException;
+use Cake\Http\Exception\NotFoundException;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\Http\Session;
+use Cake\TestSuite\TestCase;
+use Cake\Utility\Security;
+
+/**
+ * FormProtectionComponentTest class
+ */
+class FormProtectionComponentTest extends TestCase
+{
+    /**
+     * @var \Cake\Controller\Controller
+     */
+    protected $Controller;
+
+    /**
+     * @var \Cake\Controller\Component\FormProtectionComponent
+     */
+    protected $FormProtection;
+
+    /**
+     * setUp method
+     *
+     * Initializes environment state.
+     *
+     * @return void
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $session = new Session();
+        $session->id('cli');
+        $request = new ServerRequest([
+            'url' => '/articles/index',
+            'session' => $session,
+            'params' => ['controller' => 'articles', 'action' => 'index'],
+        ]);
+
+        $this->Controller = new Controller($request);
+        $this->Controller->loadComponent('FormProtection');
+        $this->FormProtection = $this->Controller->FormProtection;
+
+        Security::setSalt('foo!');
+    }
+
+    public function testConstructorSettingProperties(): void
+    {
+        $settings = [
+            'requireSecure' => ['update_account'],
+            'validatePost' => false,
+        ];
+        $FormProtection = new FormProtectionComponent($this->Controller->components(), $settings);
+        $this->assertEquals($FormProtection->validatePost, $settings['validatePost']);
+    }
+
+    public function testValidation(): void
+    {
+        $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid';
+        $unlocked = '';
+        $debug = '';
+
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([
+            'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ]));
+
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $this->assertNull($this->FormProtection->startup($event));
+    }
+
+    public function testValidationOnGetWithData(): void
+    {
+        $fields = 'an-invalid-token';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            'some-action',
+            [],
+            [],
+        ]));
+
+        $this->Controller->setRequest($this->Controller->getRequest()
+            ->withEnv('REQUEST_METHOD', 'GET')
+            ->withData('Model', ['username' => 'nate', 'password' => 'foo', 'valid' => '0'])
+            ->withData('_Token', compact('fields', 'unlocked', 'debug')));
+
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $this->expectException(BadRequestException::class);
+        $this->FormProtection->startup($event);
+    }
+
+    public function testValidationNoSession(): void
+    {
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            [],
+            [],
+        ]));
+
+        $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid';
+
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([
+            'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ]));
+
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $this->expectException(BadRequestException::class);
+        $this->expectExceptionMessage('Unexpected field `Model.password` in POST data, Unexpected field `Model.username` in POST data');
+        $this->FormProtection->startup($event);
+    }
+
+    public function testValidationEmptyForm(): void
+    {
+        $this->Controller->setRequest($this->Controller->getRequest()
+            ->withEnv('REQUEST_METHOD', 'POST')
+            ->withParsedBody([]));
+
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $this->expectException(BadRequestException::class);
+        $this->expectExceptionMessage('`_Token` was not found in request data.');
+        $this->FormProtection->startup($event);
+    }
+
+    public function testValidationFailTampering(): void
+    {
+        $unlocked = '';
+        $fields = ['Model.hidden' => 'value', 'Model.id' => '1'];
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            $fields,
+            [],
+        ]));
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $fields .= urlencode(':Model.hidden|Model.id');
+
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([
+            'Model' => [
+                'hidden' => 'tampered',
+                'id' => '1',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ]));
+
+        $this->expectException(BadRequestException::class);
+        $this->expectExceptionMessage('Tampered field `Model.hidden` in POST data (expected value `value` but found `tampered`)');
+
+        $event = new Event('Controller.startup', $this->Controller);
+        $this->FormProtection->startup($event);
+    }
+
+    public function testCallbackReturnResponse()
+    {
+        $this->FormProtection->setConfig('validationFailureCallback', function (BadRequestException $exception) {
+            return new Response(['body' => 'from callback']);
+        });
+
+        $this->Controller->setRequest($this->Controller->getRequest()
+            ->withEnv('REQUEST_METHOD', 'POST')
+            ->withParsedBody([]));
+
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $result = $this->FormProtection->startup($event);
+        $this->assertInstanceOf(Response::class, $result);
+        $this->assertSame('from callback', (string)$result->getBody());
+    }
+
+    public function testUnlockedActions(): void
+    {
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['data']));
+
+        $this->FormProtection->setConfig('unlockedActions', ['index']);
+
+        $event = new Event('Controller.startup', $this->Controller);
+        $result = $this->Controller->FormProtection->startup($event);
+
+        $this->assertNull($result);
+    }
+
+    public function testCallbackThrowsException(): void
+    {
+        $this->expectException(NotFoundException::class);
+        $this->expectExceptionMessage('error description');
+
+        $this->FormProtection->setConfig('validationFailureCallback', function (BadRequestException $exception) {
+            throw new NotFoundException('error description');
+        });
+
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['data']));
+        $event = new Event('Controller.startup', $this->Controller);
+
+        $this->Controller->FormProtection->startup($event);
+    }
+
+    public function testSettingTokenDataAsRequestAttribute(): void
+    {
+        $event = new Event('Controller.startup', $this->Controller);
+        $this->Controller->FormProtection->startup($event);
+
+        $securityToken = $this->Controller->getRequest()->getAttribute('formTokenData');
+        $this->assertNotEmpty($securityToken);
+        $this->assertSame([], $securityToken['unlockedFields']);
+    }
+
+    public function testClearingOfTokenFromRequestData(): void
+    {
+        $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['_Token' => 'data']));
+
+        $this->FormProtection->setConfig('validate', false);
+
+        $event = new Event('Controller.startup', $this->Controller);
+        $this->Controller->FormProtection->startup($event);
+
+        $this->assertSame([], $this->Controller->getRequest()->getParsedBody());
+    }
+}

+ 2 - 1
tests/TestCase/Controller/Component/SecurityComponentTest.php

@@ -13,6 +13,7 @@ declare(strict_types=1);
  * @link          https://cakephp.org CakePHP(tm) Project
  * @since         1.2.0
  * @license       https://opensource.org/licenses/mit-license.php MIT License
+ * @deprecated 4.0.0 SecurityComponent is deprecated.
  */
 namespace Cake\Test\TestCase\Controller\Component;
 
@@ -1227,7 +1228,7 @@ class SecurityComponentTest extends TestCase
         $request = $this->Controller->getRequest();
         $request = $this->Security->generateToken($request);
 
-        $securityToken = $request->getAttribute('formToken');
+        $securityToken = $request->getAttribute('formTokenData');
         $this->assertNotEmpty($securityToken);
         $this->assertSame([], $securityToken['unlockedFields']);
     }

+ 885 - 0
tests/TestCase/Form/FormProtectorTest.php

@@ -0,0 +1,885 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Form;
+
+use Cake\Core\Configure;
+use Cake\Form\FormProtector;
+use Cake\TestSuite\TestCase;
+use Cake\Utility\Security;
+
+/**
+ * FormProtectorTest class
+ */
+class FormProtectorTest extends TestCase
+{
+    /**
+     * @var string
+     */
+    protected $url = '/articles/index';
+
+    /**
+     * @var string
+     */
+    protected $sessionId = 'cli';
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        Security::setSalt('foo!');
+
+        // $this->protector = new FormProtector('http://localhost/articles/index', 'cli');
+    }
+
+    /**
+     * Helper function for validation.
+     *
+     * @param array $data
+     * @param string|null $errorMessage
+     * @return void
+     */
+    public function validate($data, $errorMessage = null)
+    {
+        $protector = new FormProtector();
+        $result = $protector->validate($data, $this->url, $this->sessionId);
+
+        if ($errorMessage === null) {
+            $this->assertTrue($result);
+        } else {
+            $this->assertFalse($result);
+            $this->assertSame($errorMessage, $protector->getError());
+        }
+    }
+
+    /**
+     * testValidate method
+     *
+     * Simple hash validation test
+     *
+     * @return void
+     */
+    public function testValidate(): void
+    {
+        $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid';
+        $unlocked = '';
+        $debug = '';
+
+        $data = [
+            'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateNoUnlockedInRequestData method
+     *
+     * Test that validate fails if you are missing unlocked in request data.
+     *
+     * @return void
+     */
+    public function testValidateNoUnlockedInRequestData(): void
+    {
+        $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid';
+
+        $data = [
+            'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('fields'),
+        ];
+
+        $this->validate($data, '`_Token.unlocked` was not found in request data.');
+    }
+
+    /**
+     * testValidateFormHacking method
+     *
+     * Test that validate fails if any of its required fields are missing.
+     *
+     * @return void
+     */
+    public function testValidateFormHacking(): void
+    {
+        $unlocked = '';
+
+        $data = [
+            'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('unlocked'),
+        ];
+
+        $this->validate($data, '`_Token.fields` was not found in request data.');
+    }
+
+    /**
+     * testValidateEmptyForm method
+     *
+     * Test that validate fails if empty form is submitted.
+     *
+     * @return void
+     */
+    public function testValidateEmptyForm(): void
+    {
+        $this->validate([], '`_Token` was not found in request data.');
+    }
+
+    /**
+     * testValidateObjectDeserialize
+     *
+     * Test that objects can't be passed into the serialized string. This was a vector for RFI and LFI
+     * attacks. Thanks to Felix Wilhelm
+     *
+     * @return void
+     */
+    public function testValidateObjectDeserialize(): void
+    {
+        $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            ['Model.password', 'Model.username', 'Model.valid'],
+            [],
+        ]));
+
+        // a corrupted serialized object, so we can see if it ever gets to deserialize
+        $attack = 'O:3:"App":1:{s:5:"__map";a:1:{s:3:"foo";s:7:"Hacked!";s:1:"fail"}}';
+        $fields .= urlencode(':' . str_rot13($attack));
+
+        $data = [
+            'Model' => ['username' => 'mark', 'password' => 'foo', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $protector = new FormProtector();
+        $result = $protector->validate($data, $this->url, $this->sessionId);
+        $this->assertFalse($result);
+    }
+
+    /**
+     * testValidateArray method
+     *
+     * Tests validation of checkbox arrays.
+     *
+     * @return void
+     */
+    public function testValidateArray(): void
+    {
+        $fields = 'f95b472a63f1d883b9eaacaf8a8e36e325e3fe82%3A';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            'some-action',
+            [],
+            [],
+        ]));
+
+        $data = [
+            'Model' => ['multi_field' => ['1', '3']],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $data = [
+            'Model' => ['multi_field' => [12 => '1', 20 => '3']],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateIntFieldName method
+     *
+     * Tests validation of integer field names.
+     *
+     * @return void
+     */
+    public function testValidateIntFieldName(): void
+    {
+        $fields = '11f87a5962db9ac26405e460cd3063bb6ff76cf8%3A';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            'some-action',
+            [],
+            [],
+        ]));
+
+        $data = [
+            1 => 'value,',
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateNoModel method
+     *
+     * @return void
+     */
+    public function testValidateNoModel(): void
+    {
+        $fields = 'a2a942f587deb20e90241c51b59d901d8a7f796b%3A';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'anything' => 'some_data',
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data);
+    }
+
+    /**
+     * test validate uses full URL
+     *
+     * @return void
+     */
+    public function testValidateSubdirectory(): void
+    {
+        $this->url = '/subdir' . $this->url;
+
+        $fields = 'cc9b6af3f33147235ae8f8037b0a71399a2425f2%3A';
+        $unlocked = '';
+        $debug = '';
+
+        $data = [
+            'Model' => ['username' => '', 'password' => ''],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateComplex method
+     *
+     * Tests hash validation for multiple records, including locked fields.
+     *
+     * @return void
+     */
+    public function testValidateComplex(): void
+    {
+        $fields = 'b00b7e5c2e3bf8bc474fb7cfde6f9c2aa06ab9bc%3AAddresses.0.id%7CAddresses.1.id';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Addresses' => [
+                '0' => [
+                    'id' => '123456', 'title' => '', 'first_name' => '', 'last_name' => '',
+                    'address' => '', 'city' => '', 'phone' => '', 'primary' => '',
+                ],
+                '1' => [
+                    'id' => '654321', 'title' => '', 'first_name' => '', 'last_name' => '',
+                    'address' => '', 'city' => '', 'phone' => '', 'primary' => '',
+                ],
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateMultipleSelect method
+     *
+     * Test ValidatePost with multiple select elements.
+     *
+     * @return void
+     */
+    public function testValidateMultipleSelect(): void
+    {
+        $fields = '28dd05f0af314050784b18b3366857e8e8c78e73%3A';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Tag' => ['Tag' => [1, 2]],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $data = [
+            'Tag' => ['Tag' => [1, 2, 3]],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $data = [
+            'Tag' => ['Tag' => [1, 2, 3, 4]],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $fields = '1e4c9269b64756e9b141d364497c5f037b428a37%3A';
+        $data = [
+            'User.password' => 'bar', 'User.name' => 'foo', 'User.is_valid' => '1',
+            'Tag' => ['Tag' => [1]],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateCheckbox method
+     *
+     * First block tests un-checked checkbox
+     * Second block tests checked checkbox
+     *
+     * @return void
+     */
+    public function testValidateCheckbox(): void
+    {
+        $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Model' => ['username' => '', 'password' => '', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $fields = '3f368401f9a8610bcace7746039651066cdcdc38%3A';
+
+        $data = [
+            'Model' => ['username' => '', 'password' => '', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $data = [
+            'Model' => ['username' => '', 'password' => '', 'valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateHidden method
+     *
+     * @return void
+     */
+    public function testValidateHidden(): void
+    {
+        $fields = '96e61bded2b62b0c420116a0eb06a3b3acddb8f1%3AModel.hidden%7CModel.other_hidden';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Model' => [
+                'username' => '', 'password' => '', 'hidden' => '0',
+                'other_hidden' => 'some hidden value',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateDisabledFieldsInData method
+     *
+     * Test validating post data with posted unlocked fields.
+     *
+     * @return void
+     */
+    public function testValidateDisabledFieldsInData(): void
+    {
+        $unlocked = 'Model.username';
+        $fields = ['Model.hidden', 'Model.password'];
+        $fields = urlencode(
+            hash_hmac('sha1', '/articles/index' . serialize($fields) . $unlocked . 'cli', Security::getSalt())
+        );
+        $debug = 'not used';
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateFailNoDisabled method
+     *
+     * Test that missing 'unlocked' input causes failure.
+     *
+     * @return void
+     */
+    public function testValidateFailNoDisabled(): void
+    {
+        $fields = ['Model.hidden', 'Model.password', 'Model.username'];
+        $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt()));
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields'),
+        ];
+
+        $this->validate($data, '`_Token.unlocked` was not found in request data.');
+    }
+
+    /**
+     * testValidateFailNoDebug method
+     *
+     * Test that missing 'debug' input causes failure.
+     *
+     * @return void
+     */
+    public function testValidateFailNoDebug(): void
+    {
+        $fields = ['Model.hidden', 'Model.password', 'Model.username'];
+        $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt()));
+        $unlocked = '';
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields', 'unlocked'),
+        ];
+
+        $this->validate($data, '`_Token.debug` was not found in request data.');
+    }
+
+    /**
+     * testValidateFailNoDebugMode method
+     *
+     * Test that missing 'debug' input is not the problem when debug mode disabled.
+     *
+     * @return void
+     */
+    public function testValidateFailNoDebugMode(): void
+    {
+        $fields = ['Model.hidden', 'Model.password', 'Model.username'];
+        $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt()));
+        $unlocked = '';
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields', 'unlocked'),
+        ];
+        Configure::write('debug', false);
+        $protector = new FormProtector();
+        $result = $protector->validate($data, $this->url, $this->sessionId);
+        $this->assertFalse($result);
+    }
+
+    /**
+     * testValidateFailDisabledFieldTampering method
+     *
+     * Test that validate fails when unlocked fields are changed.
+     *
+     * @return void
+     */
+    public function testValidateFailDisabledFieldTampering(): void
+    {
+        $unlocked = 'Model.username';
+        $fields = ['Model.hidden', 'Model.password'];
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            ['Model.hidden', 'Model.password'],
+            ['Model.username'],
+        ]));
+
+        // Tamper the values.
+        $unlocked = 'Model.username|Model.password';
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data, 'Missing field `Model.password` in POST data, Unexpected unlocked field `Model.password` in POST data');
+    }
+
+    /**
+     * testValidateHiddenMultipleModel method
+     *
+     * @return void
+     */
+    public function testValidateHiddenMultipleModel(): void
+    {
+        $fields = '642b7a6db3b848fab88952b86ea36c572f93df40%3AModel.valid%7CModel2.valid%7CModel3.valid';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Model' => ['username' => '', 'password' => '', 'valid' => '0'],
+            'Model2' => ['valid' => '0'],
+            'Model3' => ['valid' => '0'],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateHasManyModel method
+     *
+     * @return void
+     */
+    public function testValidateHasManyModel(): void
+    {
+        $fields = '792324c8a374772ad82acfb28f0e77e70f8ed3af%3AModel.0.hidden%7CModel.0.valid';
+        $fields .= '%7CModel.1.hidden%7CModel.1.valid';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Model' => [
+                [
+                    'username' => 'username', 'password' => 'password',
+                    'hidden' => 'value', 'valid' => '0',
+                ],
+                [
+                    'username' => 'username', 'password' => 'password',
+                    'hidden' => 'value', 'valid' => '0',
+                ],
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateHasManyRecordsPass method
+     *
+     * @return void
+     */
+    public function testValidateHasManyRecordsPass(): void
+    {
+        $fields = '7f4bff67558e25ebeea44c84ea4befa8d50b080c%3AAddress.0.id%7CAddress.0.primary%7C';
+        $fields .= 'Address.1.id%7CAddress.1.primary';
+        $unlocked = '';
+        $debug = 'not used';
+
+        $data = [
+            'Address' => [
+                0 => [
+                    'id' => '123',
+                    'title' => 'home',
+                    'first_name' => 'Bilbo',
+                    'last_name' => 'Baggins',
+                    'address' => '23 Bag end way',
+                    'city' => 'the shire',
+                    'phone' => 'N/A',
+                    'primary' => '1',
+                ],
+                1 => [
+                    'id' => '124',
+                    'title' => 'home',
+                    'first_name' => 'Frodo',
+                    'last_name' => 'Baggins',
+                    'address' => '50 Bag end way',
+                    'city' => 'the shire',
+                    'phone' => 'N/A',
+                    'primary' => '1',
+                ],
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateHasManyRecords method
+     *
+     * validate should fail, hidden fields have been changed.
+     *
+     * @return void
+     */
+    public function testValidateHasManyRecordsFail(): void
+    {
+        $fields = '7a203edb3d345bbf38fe0dccae960da8842e11d7%3AAddress.0.id%7CAddress.0.primary%7C';
+        $fields .= 'Address.1.id%7CAddress.1.primary';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            [
+                'Address.0.address',
+                'Address.0.city',
+                'Address.0.first_name',
+                'Address.0.last_name',
+                'Address.0.phone',
+                'Address.0.title',
+                'Address.1.address',
+                'Address.1.city',
+                'Address.1.first_name',
+                'Address.1.last_name',
+                'Address.1.phone',
+                'Address.1.title',
+                'Address.0.id' => '123',
+                'Address.0.primary' => '5',
+                'Address.1.id' => '124',
+                'Address.1.primary' => '1',
+            ],
+            [],
+        ]));
+
+        $data = [
+            'Address' => [
+                0 => [
+                    'id' => '123',
+                    'title' => 'home',
+                    'first_name' => 'Bilbo',
+                    'last_name' => 'Baggins',
+                    'address' => '23 Bag end way',
+                    'city' => 'the shire',
+                    'phone' => 'N/A',
+                    'primary' => '5',
+                ],
+                1 => [
+                    'id' => '124',
+                    'title' => 'home',
+                    'first_name' => 'Frodo',
+                    'last_name' => 'Baggins',
+                    'address' => '50 Bag end way',
+                    'city' => 'the shire',
+                    'phone' => 'N/A',
+                    'primary' => '1',
+                ],
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $protector = new FormProtector();
+        $result = $protector->validate($data, $this->url, $this->sessionId);
+        $this->assertFalse($result);
+    }
+
+    /**
+     * testValidateRadio method
+     *
+     * Test validate with radio buttons.
+     *
+     * @return void
+     * @triggers Controller.startup $this->Controller
+     */
+    public function testValidateRadio(): void
+    {
+        $fields = 'a709dfdee0a0cce52c4c964a1b8a56159bb081b4%3An%3A0%3A%7B%7D';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            [],
+            [],
+        ]));
+
+        $data = [
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $protector = new FormProtector();
+        $result = $protector->validate($data, $this->url, $this->sessionId);
+        $this->assertFalse($result);
+
+        $data = [
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+            'Test' => ['test' => ''],
+        ];
+        $this->validate($data);
+
+        $data = [
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+            'Test' => ['test' => '1'],
+        ];
+        $this->validate($data);
+
+        $data = [
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+            'Test' => ['test' => '2'],
+        ];
+        $this->validate($data);
+    }
+
+    /**
+     * testValidateUrlAsHashInput method
+     *
+     * Test validate uses here() as a hash input.
+     *
+     * @return void
+     */
+    public function testValidateUrlAsHashInput(): void
+    {
+        $fields = 'de2ca3670dd06c29558dd98482c8739e86da2c7c%3A';
+        $unlocked = '';
+        $debug = urlencode(json_encode([
+            'another-url',
+            ['Model.username', 'Model.password'],
+            [],
+        ]));
+
+        $data = [
+            'Model' => ['username' => '', 'password' => ''],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        $this->validate($data);
+
+        $this->url = '/posts/index?page=1';
+        $this->validate(
+            $data,
+            'URL mismatch in POST data (expected `another-url` but found `/posts/index?page=1`)'
+        );
+
+        $this->url = '/posts/edit/1';
+        $this->validate(
+            $data,
+            'URL mismatch in POST data (expected `another-url` but found `/posts/edit/1`)'
+        );
+    }
+
+    /**
+     * testValidateDebugFormat method
+     *
+     * Test that debug token format is right.
+     *
+     * @return void
+     */
+    public function testValidateDebugFormat(): void
+    {
+        $unlocked = 'Model.username';
+        $fields = ['Model.hidden', 'Model.password'];
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            ['Model.hidden', 'Model.password'],
+            ['Model.username'],
+            ['not expected'],
+        ]));
+
+        $data = [
+            'Model' => [
+                'username' => 'mark',
+                'password' => 'sekret',
+                'hidden' => '0',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data, 'Invalid form protection debug token.');
+
+        $debug = urlencode(json_encode('not an array'));
+        $this->validate($data, 'Invalid form protection debug token.');
+    }
+
+    /**
+     * testValidateFailTampering method
+     *
+     * Test that validate fails with tampered fields and explanation.
+     *
+     * @return void
+     */
+    public function testValidateFailTampering(): void
+    {
+        $unlocked = '';
+        $fields = ['Model.hidden' => 'value', 'Model.id' => '1'];
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            $fields,
+            [],
+        ]));
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $fields .= urlencode(':Model.hidden|Model.id');
+        $data = [
+            'Model' => [
+                'hidden' => 'tampered',
+                'id' => '1',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate($data, 'Tampered field `Model.hidden` in POST data (expected value `value` but found `tampered`)');
+    }
+
+    /**
+     * testValidateFailTamperingMutatedIntoArray method
+     *
+     * Test that validate fails with tampered fields and explanation.
+     *
+     * @return void
+     */
+    public function testValidateFailTamperingMutatedIntoArray(): void
+    {
+        $unlocked = '';
+        $fields = ['Model.hidden' => 'value', 'Model.id' => '1'];
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            $fields,
+            [],
+        ]));
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $fields .= urlencode(':Model.hidden|Model.id');
+        $data = [
+            'Model' => [
+                'hidden' => ['some-key' => 'some-value'],
+                'id' => '1',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+
+        $this->validate(
+            $data,
+            'Unexpected field `Model.hidden.some-key` in POST data, Missing field `Model.hidden` in POST data'
+        );
+    }
+
+    /**
+     * testValidateUnexpectedDebugToken method
+     *
+     * Test that debug token should not be sent if debug is disabled.
+     *
+     * @return void
+     */
+    public function testValidateUnexpectedDebugToken(): void
+    {
+        $unlocked = '';
+        $fields = ['Model.hidden' => 'value', 'Model.id' => '1'];
+        $debug = urlencode(json_encode([
+            '/articles/index',
+            $fields,
+            [],
+        ]));
+        $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt()));
+        $fields .= urlencode(':Model.hidden|Model.id');
+        $data = [
+            'Model' => [
+                'hidden' => ['some-key' => 'some-value'],
+                'id' => '1',
+            ],
+            '_Token' => compact('fields', 'unlocked', 'debug'),
+        ];
+        Configure::write('debug', false);
+        $this->validate($data, 'Unexpected `_Token.debug` found in request data');
+    }
+}

+ 210 - 160
tests/TestCase/View/Helper/FormHelperTest.php

@@ -65,6 +65,11 @@ class FormHelperTest extends TestCase
     protected $article = [];
 
     /**
+     * @var string
+     */
+    protected $url;
+
+    /**
      * setUp method
      *
      * @return void
@@ -91,6 +96,7 @@ class FormHelperTest extends TestCase
         Router::reload();
         Router::setRequest($request);
 
+        $this->url = '/articles/add';
         $this->Form = new FormHelper($this->View);
 
         $this->dateRegex = [
@@ -237,6 +243,10 @@ class FormHelperTest extends TestCase
      */
     public function testOrderForRenderingWidgetAndFetchingSecureFields()
     {
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
+            'unlockedFields' => [],
+        ]));
+
         $data = [
             'val' => 1,
             'name' => 'test',
@@ -254,6 +264,7 @@ class FormHelperTest extends TestCase
             ->with($data)
             ->will($this->returnValue(['test']));
 
+        $this->Form->create();
         $result = $this->Form->widget('test', $data + ['secure' => true]);
         $this->assertSame('HTML', $result);
     }
@@ -266,15 +277,18 @@ class FormHelperTest extends TestCase
      */
     public function testRenderingWidgetWithEmptyName()
     {
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $result = $this->Form->widget('select', ['secure' => true, 'name' => '']);
         $this->assertSame('<select name=""></select>', $result);
-        $this->assertEquals([], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals([], $result);
 
         $result = $this->Form->widget('select', ['secure' => true, 'name' => '0']);
         $this->assertSame('<select name="0"></select>', $result);
-        $this->assertEquals(['0'], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(['0'], $result);
     }
 
     /**
@@ -1079,30 +1093,19 @@ class FormHelperTest extends TestCase
     }
 
     /**
-     * test that create() clears the fields property so it starts fresh
-     *
-     * @return void
-     */
-    public function testCreateClearingFields()
-    {
-        $this->Form->fields = ['model_id'];
-        $this->Form->create($this->article);
-        $this->assertEquals([], $this->Form->fields);
-    }
-
-    /**
      * Tests form hash generation with model-less data
      *
      * @return void
      */
     public function testValidateHashNoModel()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'foo'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $fields = ['anything'];
+        $this->Form->create();
         $result = $this->Form->secure($fields);
 
-        $hash = hash_hmac('sha1', serialize($fields) . session_id(), Security::getSalt());
+        $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt());
         $this->assertStringContainsString($hash, $result);
     }
 
@@ -1113,11 +1116,13 @@ class FormHelperTest extends TestCase
      */
     public function testNoCheckboxLocking()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'foo'));
-        $this->assertSame([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
+
+        $this->assertSame([], $this->Form->getFormProtector()->__debugInfo()['fields']);
 
         $this->Form->checkbox('check', ['value' => '1']);
-        $this->assertSame($this->Form->fields, ['check']);
+        $this->assertSame(['check'], $this->Form->getFormProtector()->__debugInfo()['fields']);
     }
 
     /**
@@ -1131,14 +1136,15 @@ class FormHelperTest extends TestCase
     {
         $fields = ['Model.password', 'Model.username', 'Model.valid' => '0'];
 
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
         $result = $this->Form->secure($fields);
 
-        $hash = hash_hmac('sha1', serialize($fields) . session_id(), Security::getSalt());
+        $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt());
         $hash .= ':' . 'Model.valid';
         $hash = urlencode($hash);
         $tokenDebug = urlencode(json_encode([
-            '',
+            $this->url,
             $fields,
             [],
         ]));
@@ -1179,10 +1185,11 @@ class FormHelperTest extends TestCase
         Configure::write('debug', false);
         $fields = ['Model.password', 'Model.username', 'Model.valid' => '0'];
 
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
         $result = $this->Form->secure($fields);
 
-        $hash = hash_hmac('sha1', serialize($fields) . session_id(), Security::getSalt());
+        $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt());
         $hash .= ':' . 'Model.valid';
         $hash = urlencode($hash);
         $expected = [
@@ -1366,7 +1373,8 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityMultipleFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'foo'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $fields = [
             'Model.0.password', 'Model.0.username', 'Model.0.hidden' => 'value',
@@ -1375,10 +1383,22 @@ class FormHelperTest extends TestCase
         ];
         $result = $this->Form->secure($fields);
 
-        $hash = '8670192c3f040bf58680479060b4755b7a5c3596' .
-            '%3AModel.0.hidden%7CModel.0.valid%7CModel.1.hidden%7CModel.1.valid';
+        $sortedFields = [
+                'Model.0.password',
+                'Model.0.username',
+                'Model.1.password',
+                'Model.1.username',
+                'Model.0.hidden' => 'value',
+                'Model.0.valid' => '0',
+                'Model.1.hidden' => 'value',
+                'Model.1.valid' => '0',
+        ];
+        $hash = hash_hmac('sha1', $this->url . serialize($sortedFields) . session_id(), Security::getSalt());
+        $hash .= ':Model.0.hidden|Model.0.valid|Model.1.hidden|Model.1.valid';
+        $hash = urlencode($hash);
+
         $tokenDebug = urlencode(json_encode([
-            '',
+            $this->url,
             $fields,
             [],
         ]));
@@ -1417,7 +1437,7 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityMultipleSubmitButtons()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $this->Form->create($this->article);
         $this->Form->text('Address.title');
@@ -1481,11 +1501,11 @@ class FormHelperTest extends TestCase
      */
     public function testSecurityButtonNestedNamed()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('csrfToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $this->Form->create();
         $this->Form->button('Test', ['type' => 'submit', 'name' => 'Address[button]']);
-        $result = $this->Form->unlockField();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(['Address.button'], $result);
     }
 
@@ -1496,11 +1516,11 @@ class FormHelperTest extends TestCase
      */
     public function testSecuritySubmitNestedNamed()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $this->Form->create($this->article);
         $this->Form->submit('Test', ['type' => 'submit', 'name' => 'Address[button]']);
-        $result = $this->Form->unlockField();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(['Address.button'], $result);
     }
 
@@ -1511,7 +1531,7 @@ class FormHelperTest extends TestCase
      */
     public function testSecuritySubmitImageNoName()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $this->Form->create();
         $result = $this->Form->submit('save.png');
@@ -1521,7 +1541,9 @@ class FormHelperTest extends TestCase
             '/div',
         ];
         $this->assertHtml($expected, $result);
-        $this->assertEquals(['x', 'y'], $this->Form->unlockField());
+
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
+        $this->assertEquals(['x', 'y'], $result);
     }
 
     /**
@@ -1531,9 +1553,9 @@ class FormHelperTest extends TestCase
      */
     public function testSecuritySubmitImageName()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
-        $this->Form->create(null);
+        $this->Form->create();
         $result = $this->Form->submit('save.png', ['name' => 'test']);
         $expected = [
             'div' => ['class' => 'submit'],
@@ -1541,7 +1563,8 @@ class FormHelperTest extends TestCase
             '/div',
         ];
         $this->assertHtml($expected, $result);
-        $this->assertEquals(['test', 'test_x', 'test_y'], $this->Form->unlockField());
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
+        $this->assertEquals(['test', 'test_x', 'test_y'], $result);
     }
 
     /**
@@ -1553,7 +1576,7 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityMultipleControlFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
         $this->Form->create();
 
         $this->Form->hidden('Addresses.0.id', ['value' => '123456']);
@@ -1574,7 +1597,7 @@ class FormHelperTest extends TestCase
         $this->Form->control('Addresses.1.phone');
         $this->Form->control('Addresses.1.primary', ['type' => 'checkbox']);
 
-        $result = $this->Form->secure($this->Form->fields);
+        $result = $this->Form->secure();
         $hash = 'a4fe49bde94894a01375e7aa2873ea8114a96471%3AAddresses.0.id%7CAddresses.1.id';
         $tokenDebug = urlencode(json_encode([
             '/articles/add',
@@ -1632,14 +1655,16 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityArrayFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $this->Form->create();
         $this->Form->text('Address.primary.1');
-        $this->assertSame('Address.primary', $this->Form->fields[0]);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertSame('Address.primary', $result[0]);
 
         $this->Form->text('Address.secondary.1.0');
-        $this->assertSame('Address.secondary', $this->Form->fields[1]);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertSame('Address.secondary', $result[1]);
     }
 
     /**
@@ -1651,7 +1676,7 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityMultipleControlDisabledFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => ['first_name', 'address'],
         ]));
         $this->Form->create();
@@ -1671,7 +1696,7 @@ class FormHelperTest extends TestCase
         $this->Form->text('Addresses.1.city');
         $this->Form->text('Addresses.1.phone');
 
-        $result = $this->Form->secure($this->Form->fields);
+        $result = $this->Form->secure();
         $hash = '43c4db25e4162c5e4edd9dea51f5f9d9d92215ec%3AAddresses.0.id%7CAddresses.1.id';
         $tokenDebug = urlencode(json_encode([
                 '/articles/add',
@@ -1727,13 +1752,14 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityControlUnlockedFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => ['first_name', 'address'],
         ]));
         $this->Form->create();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(
-            $this->View->getRequest()->getAttribute('formToken'),
-            ['unlockedFields' => $this->Form->unlockField()]
+            $this->View->getRequest()->getAttribute('formTokenData'),
+            ['unlockedFields' => $result]
         );
 
         $this->Form->hidden('Addresses.id', ['value' => '123456']);
@@ -1744,7 +1770,7 @@ class FormHelperTest extends TestCase
         $this->Form->text('Addresses.city');
         $this->Form->text('Addresses.phone');
 
-        $result = $this->Form->fields;
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $expected = [
             'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name',
             'Addresses.city', 'Addresses.phone',
@@ -1805,13 +1831,14 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityControlUnlockedFieldsDebugSecurityTrue()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => ['first_name', 'address'],
         ]));
         $this->Form->create();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(
-            $this->View->getRequest()->getAttribute('formToken'),
-            ['unlockedFields' => $this->Form->unlockField()]
+            $this->View->getRequest()->getAttribute('formTokenData'),
+            ['unlockedFields' => $result]
         );
 
         $this->Form->hidden('Addresses.id', ['value' => '123456']);
@@ -1822,7 +1849,7 @@ class FormHelperTest extends TestCase
         $this->Form->text('Addresses.city');
         $this->Form->text('Addresses.phone');
 
-        $result = $this->Form->fields;
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $expected = [
             'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name',
             'Addresses.city', 'Addresses.phone',
@@ -1882,13 +1909,14 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityControlUnlockedFieldsDebugSecurityDebugFalse()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => ['first_name', 'address'],
         ]));
         $this->Form->create();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(
-            $this->View->getRequest()->getAttribute('formToken'),
-            ['unlockedFields' => $this->Form->unlockField()]
+            $this->View->getRequest()->getAttribute('formTokenData'),
+            ['unlockedFields' => $result]
         );
 
         $this->Form->hidden('Addresses.id', ['value' => '123456']);
@@ -1899,7 +1927,7 @@ class FormHelperTest extends TestCase
         $this->Form->text('Addresses.city');
         $this->Form->text('Addresses.phone');
 
-        $result = $this->Form->fields;
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $expected = [
             'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name',
             'Addresses.city', 'Addresses.phone',
@@ -1939,13 +1967,14 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecurityControlUnlockedFieldsDebugSecurityFalse()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => ['first_name', 'address'],
         ]));
         $this->Form->create();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(
-            $this->View->getRequest()->getAttribute('formToken'),
-            ['unlockedFields' => $this->Form->unlockField()]
+            $this->View->getRequest()->getAttribute('formTokenData'),
+            ['unlockedFields' => $result]
         );
 
         $this->Form->hidden('Addresses.id', ['value' => '123456']);
@@ -1956,7 +1985,7 @@ class FormHelperTest extends TestCase
         $this->Form->text('Addresses.city');
         $this->Form->text('Addresses.phone');
 
-        $result = $this->Form->fields;
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $expected = [
             'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name',
             'Addresses.city', 'Addresses.phone',
@@ -1997,13 +2026,16 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecureWithCustomNameAttribute()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->text('UserForm.published', ['name' => 'User[custom]']);
-        $this->assertSame('User.custom', $this->Form->fields[0]);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertSame('User.custom', $result[0]);
 
         $this->Form->text('UserForm.published', ['name' => 'User[custom][another][value]']);
-        $this->assertSame('User.custom.another.value', $this->Form->fields[1]);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertSame('User.custom.another.value', $result[1]);
     }
 
     /**
@@ -2016,7 +2048,7 @@ class FormHelperTest extends TestCase
     public function testFormSecuredControl()
     {
         $this->View->setRequest($this->View->getRequest()
-            ->withAttribute('formToken', 'stuff')
+            ->withAttribute('formTokenData', [])
             ->withAttribute('csrfToken', 'testKey'));
         $this->article['schema'] = [
             'ratio' => ['type' => 'decimal', 'length' => 5, 'precision' => 6],
@@ -2130,7 +2162,7 @@ class FormHelperTest extends TestCase
         ];
         $this->assertHtml($expected, $result);
 
-        $result = $this->Form->fields;
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $expectedFields = [
             'ratio',
             'population',
@@ -2142,7 +2174,7 @@ class FormHelperTest extends TestCase
         ];
         $this->assertEquals($expectedFields, $result);
 
-        $result = $this->Form->secure($this->Form->fields);
+        $result = $this->Form->secure();
         $tokenDebug = urlencode(json_encode([
             '/articles/add',
             $expectedFields,
@@ -2182,24 +2214,27 @@ class FormHelperTest extends TestCase
      */
     public function testSecuredControlCustomName()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->text('text_input', [
             'name' => 'Option[General.default_role]',
         ]);
         $expected = ['Option.General.default_role'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->select('select_box', [1, 2], [
             'name' => 'Option[General.select_role]',
         ]);
         $expected[] = 'Option.General.select_role';
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->text('other.things[]');
         $expected[] = 'other.things';
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2212,21 +2247,23 @@ class FormHelperTest extends TestCase
      */
     public function testSecuredControlDuplicate()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->control('text_val', [
                 'type' => 'hidden',
                 'value' => 'some text',
         ]);
         $expected = ['text_val' => 'some text'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->control('text_val', [
                 'type' => 'text',
         ]);
         $expected = ['text_val'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2238,7 +2275,8 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecuredFileControl()
     {
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->file('Attachment.file');
         $expected = [
@@ -2246,7 +2284,8 @@ class FormHelperTest extends TestCase
             'Attachment.file.tmp_name', 'Attachment.file.error',
             'Attachment.file.size',
         ];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2258,17 +2297,19 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecuredMultipleSelect()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('csrfToken', 'testKey'));
-        $this->assertEquals([], $this->Form->fields);
-        $options = ['1' => 'one', '2' => 'two'];
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
+        $options = ['1' => 'one', '2' => 'two'];
         $this->Form->select('Model.select', $options);
         $expected = ['Model.select'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->fields = [];
         $this->Form->select('Model.select', $options, ['multiple' => true]);
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2278,26 +2319,29 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecuredRadio()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
-        $this->assertEquals([], $this->Form->fields);
         $options = ['1' => 'option1', '2' => 'option2'];
 
         $this->Form->radio('Test.test', $options);
         $expected = ['Test.test'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->radio('Test.all', $options, [
             'disabled' => ['option1', 'option2'],
         ]);
         $expected = ['Test.test', 'Test.all' => ''];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->radio('Test.some', $options, [
             'disabled' => ['option1'],
         ]);
         $expected = ['Test.test', 'Test.all' => '', 'Test.some'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2309,7 +2353,8 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecuredAndDisabledNotAssoc()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->select('Model.select', [1, 2], ['disabled']);
         $this->Form->checkbox('Model.checkbox', ['disabled']);
@@ -2321,7 +2366,8 @@ class FormHelperTest extends TestCase
         $expected = [
             'Model.radio' => '',
         ];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -2334,7 +2380,8 @@ class FormHelperTest extends TestCase
      */
     public function testFormSecuredAndDisabled()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->checkbox('Model.checkbox', ['disabled' => true]);
         $this->Form->text('Model.text', ['disabled' => true]);
@@ -2352,31 +2399,7 @@ class FormHelperTest extends TestCase
         $expected = [
             'Model.radio' => '',
         ];
-        $this->assertEquals($expected, $this->Form->fields);
-    }
-
-    /**
-     * testDisableSecurityUsingForm method
-     *
-     * @return void
-     */
-    public function testDisableSecurityUsingForm()
-    {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
-            'disabledFields' => [],
-        ]));
-        $this->Form->create();
-
-        $this->Form->hidden('Addresses.id', ['value' => '123456']);
-        $this->Form->text('Addresses.title');
-        $this->Form->text('Addresses.first_name', ['secure' => false]);
-        $this->Form->textarea('Addresses.city', ['secure' => false]);
-        $this->Form->select('Addresses.zip', [1, 2], ['secure' => false]);
-
-        $result = $this->Form->fields;
-        $expected = [
-            'Addresses.id' => '123456', 'Addresses.title',
-        ];
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
         $this->assertEquals($expected, $result);
     }
 
@@ -2389,14 +2412,19 @@ class FormHelperTest extends TestCase
      */
     public function testUnlockFieldAddsToList()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => [],
         ]));
+        $this->Form->create();
+
         $this->Form->unlockField('Contact.name');
         $this->Form->text('Contact.name');
 
-        $this->assertEquals(['Contact.name'], $this->Form->unlockField());
-        $this->assertEquals([], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals([], $result);
+
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
+        $this->assertEquals(['Contact.name'], $result);
     }
 
     /**
@@ -2408,19 +2436,21 @@ class FormHelperTest extends TestCase
      */
     public function testUnlockFieldRemovingFromFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'unlockedFields' => [],
         ]));
         $this->Form->create($this->article);
         $this->Form->hidden('Article.id', ['value' => 1]);
         $this->Form->text('Article.title');
 
-        $this->assertEquals(1, $this->Form->fields['Article.id'], 'Hidden input should be secured.');
-        $this->assertContains('Article.title', $this->Form->fields, 'Field should be secured.');
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(1, $result['Article.id'], 'Hidden input should be secured.');
+        $this->assertContains('Article.title', $result, 'Field should be secured.');
 
         $this->Form->unlockField('Article.title');
         $this->Form->unlockField('Article.id');
-        $this->assertEquals([], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals([], $result);
     }
 
     /**
@@ -2432,20 +2462,22 @@ class FormHelperTest extends TestCase
      */
     public function testResetUnlockFields()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', [
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [
             'key' => 'testKey',
             'unlockedFields' => [],
         ]));
 
-        $this->Form->unlockField('Contact.id');
         $this->Form->create();
+        $this->Form->unlockField('Contact.id');
         $this->Form->hidden('Contact.id', ['value' => 1]);
-        $this->assertEmpty($this->Form->fields, 'Field should be unlocked');
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEmpty($result, 'Field should be unlocked');
         $this->Form->end();
 
-        $this->Form->create(null);
+        $this->Form->create();
         $this->Form->hidden('Contact.id', ['value' => 1]);
-        $this->assertEquals(1, $this->Form->fields['Contact.id'], 'Hidden input should be secured.');
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(1, $result['Contact.id'], 'Hidden input should be secured.');
     }
 
     /**
@@ -2457,7 +2489,7 @@ class FormHelperTest extends TestCase
      */
     public function testSecuredFormUrlIgnoresHost()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', ['key' => 'testKey']));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', ['key' => 'testKey']));
 
         $expected = '2548654895b160d724042ed269a2a863fd9d66ee%3A';
         $this->Form->create($this->article, [
@@ -2488,7 +2520,7 @@ class FormHelperTest extends TestCase
      */
     public function testSecuredFormUrlHasHtmlAndIdentifier()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
 
         $expected = '0a913f45b887b4d9cc2650ef1edc50183896959c%3A';
         $this->Form->create($this->article, [
@@ -5538,19 +5570,21 @@ class FormHelperTest extends TestCase
      */
     public function testSelectMultipleCheckboxSecurity()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testKey'));
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->select(
             'Model.multi_field',
             ['1' => 'first', '2' => 'second', '3' => 'third'],
             ['multiple' => 'checkbox']
         );
-        $this->assertEquals(['Model.multi_field'], $this->Form->fields);
+        $fields = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(['Model.multi_field'], $fields);
 
-        $result = $this->Form->secure($this->Form->fields);
-        $key = '8af36fb34e6f2ef8ba0eb473bb4365ec232f3fe5%3A';
-        $this->assertStringContainsString('"' . $key . '"', $result);
+        $result = $this->Form->secure();
+        $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt());
+        $hash = urlencode($hash . ':');
+        $this->assertStringContainsString('"' . $hash . '"', $result);
     }
 
     /**
@@ -5563,14 +5597,16 @@ class FormHelperTest extends TestCase
      */
     public function testSelectMultipleSecureWithNoOptions()
     {
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->select(
             'Model.select',
             [],
             ['multiple' => true]
         );
-        $this->assertEquals(['Model.select'], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(['Model.select'], $result);
     }
 
     /**
@@ -5583,21 +5619,23 @@ class FormHelperTest extends TestCase
      */
     public function testSelectNoSecureWithNoOptions()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'testkey'));
-        $this->assertEquals([], $this->Form->fields);
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
 
         $this->Form->select(
             'Model.select',
             []
         );
-        $this->assertEquals([], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals([], $result);
 
         $this->Form->select(
             'Model.user_id',
             [],
             ['empty' => true]
         );
-        $this->assertEquals(['Model.user_id'], $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(['Model.user_id'], $result);
     }
 
     /**
@@ -6021,16 +6059,20 @@ class FormHelperTest extends TestCase
     public function testDateTimeSecured()
     {
         $this->View->setRequest(
-            $this->View->getRequest()->withAttribute('formToken', ['unlockedFields' => []])
+            $this->View->getRequest()->withAttribute('formTokenData', ['unlockedFields' => []])
         );
+        $this->Form->create();
+
         $this->Form->dateTime('date');
         $expected = ['date'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->fields = [];
         $this->Form->date('published');
-        $expected = ['published'];
-        $this->assertEquals($expected, $this->Form->fields);
+        $expected = ['date', 'published'];
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -6043,16 +6085,20 @@ class FormHelperTest extends TestCase
     public function testDateTimeSecuredDisabled()
     {
         $this->View->setRequest(
-            $this->View->getRequest()->withAttribute('formToken', ['unlockedFields' => []])
+            $this->View->getRequest()->withAttribute('formTokenData', ['unlockedFields' => []])
         );
+        $this->Form->create();
+
         $this->Form->dateTime('date', ['secure' => false]);
         $expected = [];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
 
         $this->Form->fields = [];
         $this->Form->date('published', ['secure' => false]);
         $expected = [];
-        $this->assertEquals($expected, $this->Form->fields);
+        $result = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -6544,11 +6590,13 @@ class FormHelperTest extends TestCase
      */
     public function testButtonUnlockedByDefault()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('csrfToken', 'secured'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
+
         $this->Form->button('Save', ['name' => 'save']);
         $this->Form->button('Clear');
 
-        $result = $this->Form->unlockField();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(['save'], $result);
     }
 
@@ -6675,7 +6723,7 @@ class FormHelperTest extends TestCase
     {
         $this->View->setRequest($this->View->getRequest()
             ->withAttribute('csrfToken', 'testkey')
-            ->withAttribute('formToken', ['unlockedFields' => []]));
+            ->withAttribute('formTokenData', ['unlockedFields' => []]));
 
         $result = $this->Form->postButton('Delete', '/posts/delete/1');
         $tokenDebug = urlencode(json_encode([
@@ -6899,7 +6947,7 @@ class FormHelperTest extends TestCase
     {
         $hash = hash_hmac('sha1', '/posts/delete/1' . serialize(['id' => '1']) . session_id(), Security::getSalt());
         $hash .= '%3Aid';
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', ['key' => 'test']));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', ['key' => 'test']));
 
         $result = $this->Form->postLink(
             'Delete',
@@ -6952,14 +7000,15 @@ class FormHelperTest extends TestCase
     {
         $hash = hash_hmac('sha1', '/posts/delete/1' . serialize([]) . session_id(), Security::getSalt());
         $hash .= '%3A';
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', ['key' => 'test']));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', ['key' => 'test']));
 
         $this->Form->create(null, ['url' => ['action' => 'add']]);
         $this->Form->control('title');
         $this->Form->postLink('Delete', '/posts/delete/1', ['block' => true]);
         $result = $this->View->fetch('postLink');
 
-        $this->assertEquals(['title'], $this->Form->fields);
+        $fields = $this->Form->getFormProtector()->__debugInfo()['fields'];
+        $this->assertEquals(['title'], $fields);
         $this->assertStringContainsString($hash, $result, 'Should contain the correct hash.');
         $reflect = new ReflectionProperty($this->Form, '_lastAction');
         $reflect->setAccessible(true);
@@ -6979,7 +7028,7 @@ class FormHelperTest extends TestCase
         $hash = hash_hmac('sha1', '/posts/delete/1' . serialize(['id' => '1']) . session_id(), Security::getSalt());
         $hash .= '%3Aid';
         $this->View->setRequest($this->View->getRequest()
-            ->withAttribute('formToken', ['key' => 'test']));
+            ->withAttribute('formTokenData', ['key' => 'test']));
 
         $result = $this->Form->postLink(
             'Delete',
@@ -7038,7 +7087,7 @@ class FormHelperTest extends TestCase
     {
         $this->View->setRequest($this->View->getRequest()
             ->withAttribute('csrfToken', 'testkey')
-            ->withAttribute('formToken', 'val'));
+            ->withAttribute('formTokenData', []));
 
         $this->Form->create($this->article, ['type' => 'get']);
         $this->Form->end();
@@ -7252,11 +7301,12 @@ class FormHelperTest extends TestCase
      */
     public function testSubmitUnlockedByDefault()
     {
-        $this->View->setRequest($this->View->getRequest()->withAttribute('formToken', 'secured'));
+        $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', []));
+        $this->Form->create();
         $this->Form->submit('Go go');
         $this->Form->submit('Save', ['name' => 'save']);
 
-        $result = $this->Form->unlockField();
+        $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields'];
         $this->assertEquals(['save'], $result, 'Only submits with name attributes should be unlocked.');
     }