One form validation function should be able to cancel other validation steps

Created on 1 September 2015, almost 9 years ago
Updated 4 June 2024, 14 days ago

Transferred from https://security.drupal.org/node/134653 since this was determined to be a feature request for FAPI, as opposed to a security vulnerability.

Problem/Motivation

Example 1 reported by hejazee →
==================
some form validators should take precedence. for example the validator should be executed first.
and if it fails, other validators should not be executed.

because form validators set messages on the form.
and if the initial validation fails, the user (attacker bot) should not see if other fields are valid since this could expose unwanted information.

Example 2 reported by jpcondon → (private issue)
==================
This module has a denial of service vulnerability.

You can see this vulnerability by:
1. Install Drupal.
2. Create content type with file field with two different restrictions on it, such as file size and file type.
3. Upload file with wrong file type and wrong file size and observe that both error messages appear.

While this isn't a big deal when simply validating file type or size, this presents a larger issue when using a module like the ClamAV module where it will attempt to scan the file even if other file validation errors have occurred. If a file is excessively large it will still be scanned which could cause a denial of service on the website.

Proposed resolution

Create a cancel validation variable, getter, and setter in the form state to track whether subsequent form validation functions should be skipped, i.e. if it is preferred that further validation functions should be prevented from executing, e.g.: you might set setValidationCanceled(TRUE) if a CSRF token is not valid, because it doesn't make sense to perform any further validation (and it might be a security risk to do so).

Remaining tasks

Decide how to add this feature in a backward-compatible way.

Commentary from @David_Rothstein:

There probably isn't a way to actually prevent other validation functions from running currently, but there should be some ways to prevent their error messages from displaying... Here's what I wrote elsewhere:

I don't think we necessarily need a change to Drupal core for this. I haven't looked too deeply into it, but couldn't you at least use something like form_get_errors(), form_set_error(), and form_clear_error() to clear out all validation errors on the form (at the end of the validation process) and then re-set the ones from the desired field only? I think something like this technique might work for both Drupal 6 and Drupal 7.

Also for Drupal 7 there's $form_state['triggering_element']['#limit_validation_errors'] which is a cleaner way to tell Drupal to ignore (and not display) certain form validation errors, but perhaps there's no place in the code where you can set that during form validation and have it work on the rest of the validation... not sure.

User interface changes

n/a

API changes

Some new public functions to handle cancellation:

  • FormValidator::cancelValidation
  • FormStateInterface::setValidationCanceled
  • FormStateInterface::isValidationCanceled

See change record → for more details.

Data model changes

n/a

✨ Feature request
Status

Needs work

Version

11.0 🔥

Component
Form  →

Last updated about 1 hour ago

Created by

🇺🇸United States pwolanin

Live updates comments and jobs are added and updated live.
  • Security improvements

    It makes Drupal less vulnerable to abuse or misuse. Note, this is the preferred tag, though the Security tag has a large body of issues tagged to it. Do NOT publicly disclose security vulnerabilities; contact the security team instead. Anyone (whether security team or not) can apply this tag to security improvements that do not directly present a vulnerability e.g. hardening an API to add filtering to reduce a common mistake in contributed modules.

Sign in to follow issues

Merge Requests

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • 🇩🇪Germany Anybody Porta Westfalica

    As this would still make sense for modules like CAPTCHA ( ✨ [PP-1][2.x] Do not execute other form validations if CAPTCHA is wrong Postponed ) or any other modules like honeypot, that should definitely run their validation before other requests are made, does it make sense to reroll the patch for Drupal 10.1.x?

    I guess it would be helpful to have a core maintainer discussion about this first before investing further time here?

    See the linked issues for a real-world benefit and use case.

  • 🇺🇸United States DamienMcKenna NH, USA

    Should it be something that each form validator could control? e.g.

    +    // Allow a validator to stop processing other validators if it hits a
    +    // critical problem.
    +    $continue_validators = TRUE;
         foreach ($handlers as $callback) {
    -      call_user_func_array($form_state->prepareCallback($callback), [&$form, &$form_state]);
    +      if (!$continue_validators && !$form_state->hasAnyErrors()) {
    +        $continue_validators = call_user_func_array($form_state->prepareCallback($callback), [&$form, &$form_state]) ?? TRUE;
    +      }
         }
    
  • 🇩🇪Germany Anybody Porta Westfalica

    Or we use different types of errors or flags for the errors in the validation callback?

    I guess in most cases the error in validation should determine if other validations should happen or not. $form_state might be the right place for that and nice DX?

    Something like:

    /**
     * {@inheritdoc}
     */
    public function validateForm(array &$form, FormStateInterface $form_state) {
      if (strlen($form_state->getValue('phone_number')) < 3) {
        $form_state->setCriticalErrorByName('phone_number', $this->t('The phone number is too short. Please enter a full phone number.'));
      }
    }
    

    Note: $form_state->setCriticalErrorByName(); (+ Critical)

    or

    /**
     * {@inheritdoc}
     */
    public function validateForm(array &$form, FormStateInterface $form_state) {
      if (strlen($form_state->getValue('phone_number')) < 3) {
        $form_state->setErrorByName('phone_number', $this->t('The phone number is too short. Please enter a full phone number.'));
    
        // This is a critical error, validation should not proceed with this error:
        $form_state->cancelValidation();
      }
    }
    

    Note: $form_state->cancelValidation();

    Just an idea...

  • Status changed to Needs review about 1 year ago
  • 🇨🇦Canada AlexGreen

    Anybody, I like your suggestion of cancelValidation(), I tried implementing it in this patch. Since it is so different from the previous patches, I just wrote it from scratch, so there is no interdiff. (I created this patch on 9.5 because that's how my test environment was setup at the time). The only place there might be a problem (the patch may not apply) is in the test, if that's the case, I'll fix it in short order.

  • 🇨🇦Canada AlexGreen

    Patch applied successfully, but test bot had opinions on spelling and indentation. Here's a new patch generated against 10.1 this time.

  • 🇩🇪Germany Anybody Porta Westfalica

    Thank you @AlexGreen, that's looking good! :)
    What do @tim.plunkett, @David_Rothstein, @DamienMcKenna and the others think about this way? Would be nice to get feedback to be able to proceed here. Who else should be involved?

  • 🇧🇪Belgium borisson_ Mechelen, 🇧🇪

    Tagging for subsystem maintainers review to answer #35

  • Status changed to Needs work about 1 year ago
  • 🇺🇸United States tim.plunkett Philadelphia
    +++ b/core/lib/Drupal/Core/Form/FormValidator.php
    @@ -109,8 +112,7 @@ class FormValidator implements FormValidatorInterface {
    -        $this->finalizeValidation($form, $form_state, $form_id);
    -        return;
    +        $this->cancelValidation($form, $form_state, $form_id);
    

    This change (and the non-fail-ing-ness of the patch) mean that we're missing test coverage. Because now nothing is calling finalizeValidation(), which means nothing is calling setValidationComplete(), which means that $form_state->isValidationComplete() will start returning FALSE

    I have no recollection of this issue, but apparently I wrote a patch for this 8 years ago? Cool.

  • Status changed to Needs review about 1 year ago
  • last update about 1 year ago
    29,368 pass
  • 🇨🇦Canada AlexGreen

    @tim.plunkett Is this what you meant?

  • 🇺🇸United States tim.plunkett Philadelphia
    +++ b/core/tests/Drupal/Tests/Core/Form/FormValidatorTest.php
    @@ -132,7 +132,7 @@ class FormValidatorTest extends UnitTestCase {
    -    $this->assertFalse($form_state->isValidationComplete());
    +    $this->assertTrue($form_state->isValidationComplete());
    
    @@ -407,7 +407,7 @@ class FormValidatorTest extends UnitTestCase {
    -    $this->assertFalse($form_state->isValidationComplete());
    +    $this->assertTrue($form_state->isValidationComplete());
    

    Oh yeah, the interdiff shows it clearly. With that last fix, we're no longer changing existing behavior 👍🏻

  • Status changed to Needs work about 1 year ago
  • 🇺🇸United States smustgrave

    Real nitpicky but can the new functions have a typehint return?

    Also think it would benefit from a change record for the new functions. Maybe an example of how they can be used.

  • 🇨🇦Canada AlexGreen

    I'll write a change record for this.

  • Status changed to Needs review about 1 year ago
  • 🇨🇦Canada AlexGreen

    Here is a draft change record, any suggestions/reviews welcome https://www.drupal.org/node/3360541 →

  • last update about 1 year ago
    Custom Commands Failed
  • 🇨🇦Canada AlexGreen

    @smustgrave Here is a new patch with the return typehints.

  • 🇨🇦Canada AlexGreen
    Running PHPStan on *all* files.
     ------ ---------------------------------------------------------------- 
      Line   core/tests/Drupal/Tests/Core/Form/FormValidatorTest.php         
     ------ ---------------------------------------------------------------- 
      390    Trying to mock an undefined method element_validate() on class  
             stdClass.                                                       
     ------ ---------------------------------------------------------------- 
    

    Not exactly sure how to fix this error, it was working in #38 and I didn't change this code. @smustgrave

  • last update about 1 year ago
    Custom Commands Failed
  • last update about 1 year ago
    Custom Commands Failed
  • 🇺🇸United States smustgrave

    Reran #38 and see the same. Most likely another ticket was merged and will have to update this one. Should see if that method is on the class being mocked anymore or if it moved

    May have some time tomorrow to help take a look.

  • 🇺🇸United States tim.plunkett Philadelphia
  • 🇺🇸United States tim.plunkett Philadelphia

    đź“Ś Only mock methods defined in interfaces Fixed changed the existing usages of stdClass in this test, changing this one to match.

    And switch to an MR.

  • last update about 1 year ago
    29,388 pass, 2 fail
  • last update about 1 year ago
    29,388 pass, 2 fail
  • last update about 1 year ago
    29,389 pass
  • Status changed to RTBC about 1 year ago
  • 🇺🇸United States smustgrave

    Updated the CR to be introduced in 10.2.

  • Open on Drupal.org →
    Environment: PHP 8.2 & MySQL 8
    last update about 1 year ago
    Waiting for branch to pass
  • 29:32
    25:38
    Running
  • 29:32
    27:58
    Running
  • last update about 1 year ago
    29,399 pass
  • last update about 1 year ago
    29,402 pass
  • last update about 1 year ago
    29,402 pass
  • last update about 1 year ago
    29,403 pass
  • last update about 1 year ago
    29,412 pass
  • last update about 1 year ago
    29,417 pass
  • last update about 1 year ago
    29,435 pass
  • last update about 1 year ago
    Build Successful
  • last update about 1 year ago
    29,438 pass
  • last update about 1 year ago
    29,446 pass
  • last update about 1 year ago
    29,451 pass
  • last update about 1 year ago
    29,500 pass
  • last update 12 months ago
    29,500 pass
  • last update 12 months ago
    29,509 pass
  • last update 12 months ago
    29,554 pass
  • last update 12 months ago
    29,554 pass
  • last update 12 months ago
    29,560 pass
  • last update 12 months ago
    29,568 pass
  • last update 12 months ago
    29,572 pass
  • last update 12 months ago
    29,802 pass
  • last update 12 months ago
    29,802 pass
  • last update 12 months ago
    29,803 pass
  • last update 11 months ago
    29,803 pass, 2 fail
  • last update 11 months ago
    29,812 pass
  • last update 11 months ago
    29,815 pass
  • First commit to issue fork.
  • last update 11 months ago
    29,816 pass
  • 🇪🇸Spain tunic Madrid

    I've rebased 561295-one-form-validation to 11.x so it is mergeable again.

  • Status changed to Needs work 11 months ago
  • 🇬🇧United Kingdom catch

    Left some comments on the MR.

    Apart from those, I'm slightly concerned about the 'canceled' naming. Canceled usually means something is called off, but this is short circuiting/finishing early. The word that came to mind is 'terminate' i.e. setValidationTerminated() etc. Or even just 'finished' like 'setValidationFinished()'?

  • Status changed to Needs review 11 months ago
  • 🇨🇦Canada AlexGreen

    @catch In this case, we think "terminated" is the closer concept that this change is intending to signify. We could change it to setValidationTerminated() if that's desired.

  • 🇺🇸United States smustgrave

    For the open threads. Seems at minimum comments were requested to be updated.

  • Status changed to Needs work 11 months ago
  • 🇺🇸United States smustgrave
  • last update 9 months ago
    30,149 pass
  • last update 9 months ago
    Custom Commands Failed
  • last update 9 months ago
    Custom Commands Failed
  • last update 9 months ago
    30,056 pass, 10 fail
  • last update 8 months ago
    30,300 pass, 10 fail
  • Pipeline finished with Failed
    8 months ago
    Total: 848s
    #28949
  • 🇨🇦Canada AlexGreen

    @catch I have left a comment on the merge request regarding the question raised in your review.

  • 🇬🇧United Kingdom catch

    Replied in the MR - my initial review was completely wrong, but the change you suggested makes it a lot more obvious what's happening and should prevent others from getting confused too, so looks great.

  • 🇪🇸Spain tunic Madrid

    This patch allows validators to cancel validation and I think is a good improvement. However, it would be great to be able to prioritize validations as said in the issue report. So, for example, taking example 2, Clam AV scan would have a low priority so an uploaded file is only scanned if everything else is valid. Or let's think on a form with some kind of authorization field and some other fields: if the authorization field is wrong the other fields are not validated, avoiding information leaking about those fields.

    Currently, validators are run from bottom to top while traversing the form array. We could just collect them and run in the given priority. The format of the validation callback would change form just a callback name to an object with the callback and the priority.

    Currently, you can only prioritize validators in the same level (or form elem: the root elem or any single elem) but not between them.

    With priorities this improvement would be even more useful. Should I create a follow up about this? Discuss it somewhere?

  • 🇬🇧United Kingdom catch

    @tunic a follow-up for that sounds good.

  • 🇪🇸Spain tunic Madrid

    Follow-up added!

    ✨ Add priority to form validators Active

  • Pipeline finished with Success
    7 months ago
    Total: 653s
    #45231
  • Pipeline finished with Success
    7 months ago
    Total: 670s
    #45242
  • Status changed to Needs review 7 months ago
  • 🇨🇦Canada AlexGreen

    Ready for re-review @catch

  • Status changed to RTBC 7 months ago
  • 🇺🇸United States smustgrave

    Seems that all feedback has been addressed.

    Looking at the CR still seems relevant, but could use a 2nd look over.

  • last update 7 months ago
    30,494 pass
  • Pipeline finished with Failed
    7 months ago
    Total: 1451s
    #45653
  • Status changed to Needs work 7 months ago
  • 🇺🇸United States smustgrave

    The failure seems random but I've reran multiple times so wonder if HEAD is broken.

  • Pipeline finished with Success
    7 months ago
    Total: 826s
    #59185
  • Status changed to RTBC 7 months ago
  • 🇨🇦Canada AlexGreen

    Tests are passing, back to RTBC

  • 14:31
    8:46
    Running
  • last update 6 months ago
    30,696 pass, 1 fail
  • last update 6 months ago
    30,699 pass
  • last update 6 months ago
    30,703 pass
  • last update 6 months ago
    29,612 pass, 141 fail
  • last update 6 months ago
    30,765 pass
  • last update 6 months ago
    30,767 pass
  • last update 6 months ago
    25,887 pass, 1,819 fail
  • last update 6 months ago
    25,899 pass, 1,843 fail
  • last update 6 months ago
    25,909 pass, 1,839 fail
  • last update 6 months ago
    25,968 pass, 1,815 fail
  • Status changed to Needs review 6 months ago
  • 🇷🇴Romania claudiu.cristea Arad 🇷🇴

    2 questions in MR

  • Status changed to Needs work 6 months ago
  • 🇳🇿New Zealand quietone New Zealand

    I'm triaging RTBC issues → . It is always nice to see older issues getting close to commit. Thanks!

    I read the IS, skimmed through the comments and the MR, and read the change record. I didn't find any unanswered questions.

    I do see that the issue summary has not been updated since 2015. And I think the proposed resolution is out of date. That should be up to date to help reviewers of this issue as well as anyone who winds up here doing any research.

    In #62 @smustgrave, thinks the change record is relevant but wants another set of eyes on it. That has not happened. I am excluding myself from that because I am not familiar with the issue. I will add that I think it should begin with a sentence or two to explain the value of this change. Or to answer the question, why, as a developer would I want to use this feature?

    Due to the importance of an up to date and accurate proposed resolution section in the Issue Summary I am setting this to Needs work. Also, someone else should check the change record. Fortunately, those steps should be straightforward.

  • Pipeline finished with Failed
    5 months ago
    Total: 292s
    #84504
  • Status changed to Needs review 5 months ago
  • 🇨🇦Canada AlexGreen

    Fixed strict typing and updated the proposed resolution to match how we actually resolved the issue.

  • Pipeline finished with Failed
    5 months ago
    Total: 319s
    #84518
  • Status changed to Needs work 5 months ago
  • The Needs Review Queue Bot → tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

    This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

    Consult the Drupal Contributor Guide → to find step-by-step guides for working with issues.

  • Pipeline finished with Success
    5 months ago
    Total: 488s
    #84525
  • Status changed to Needs review 5 months ago
  • 🇪🇸Spain tunic Madrid

    Issue reported by the bot has been corrected, so moving to Needs Review again.

  • 🇺🇸United States smustgrave

    smustgrave → changed the visibility of the branch 11.x to hidden.

  • 🇺🇸United States smustgrave

    Would this count as an API change? I feel yes but not familiar with the issue to say. But should be added to issue summary.

  • 🇪🇸Spain tunic Madrid

    Updated summary with three public functions that are API changes in the current MR.

    I also think this would need a change record.

  • 🇬🇧United Kingdom catch

    There's already a change record at https://www.drupal.org/node/3360541 → , it would be useful if people could review it to make sure it's up to date with the current state of the MR though.

  • 🇪🇸Spain tunic Madrid

    Ops, I missed that.

    I've reviewed the change record and the MR changes and the change record seems up to date to me.

  • Status changed to RTBC 4 months ago
  • 🇺🇸United States smustgrave

    Believe everything is in order. Believe this is good.

    Resolved my own thread and applied the suggestion :void to the test.

  • Pipeline finished with Success
    4 months ago
    Total: 477s
    #102560
  • Status changed to Needs work 3 months ago
  • 🇬🇧United Kingdom alexpott 🇪🇺🌍

    Needs work for @nod_'s comment on the MR.

  • 🇬🇧United Kingdom alexpott 🇪🇺🌍

    Also - if you cancel validation and there are no errors set yet should the form submit? I don't think it should. I read the code and thought it might not submit - but after re-reading I think it will and I think this is wrong and potentially ends up with way worse security issues.

    Also I think we should consider changing the name from cancel validation to halt validation in light of the behaviour change I'm suggesting above.

  • 🇪🇸Spain tunic Madrid

    I agree with @alexpott, if the form cancels validation and there are no errors it seems submit handlers will be run.

    This is because FormBuilder::processForm runs submit handlers if form has no errors and is not rebuilding, see line 595:

    // If there are no errors and the form is not rebuilding, submit the form.
    if (!$form_state->isRebuilding() && !FormState::hasAnyErrors()) {
      $submit_response = $this->formSubmitter->doSubmitForm($form, $form_state);
    

    FormState::hasAnyErrors uses internally a static variable in FormState, $anyErrors. I'm not sure why a static variable is used (maybe because subforms?), but anyway FormState::setAnyErrors is not called anywhere in the MR so that's why I think submit handlers will run.

    I think the problem lies in FormBuilder::processForm. It should only run submit handlers if form is not rebuilding AND validation has completed without errors, not just no errors were found.

    Either FormBuilder::processForm checks also FormState::isValidationCanceled or FormState provides a way to check validation was completed without errors in a new method. The first approach is easier, but I think is FormState who should be in charge of deciding if the form was completely validated instead of requiring two calls (logic should be inside FormState, not force users of FormState to remember to perform these two checks). However, because the error check uses this static method and an internal static variable, I'm not sure how to do it because I don't know why a static variable is used in the first place.

    Also, the name change proposed is a good idea, it's clearer and describes more accurately the proposed behavior.

  • 🇨🇦Canada AlexGreen

    @alexpott, @tunic, thank you for the feedback.

    It sounds like are suggesting the following path forward... do you agree? Anything that I missed?

    1. Change substr($callback, 0, 2) == '::' back to str_starts_with($callback, '::') as per @nod_'s suggestion
    2. Change cancel and canceled to halt and halted respectively
    3. Find some way to abort submitting the form or otherwise prevent running submit handlers when the form state reaches the "halted" state (previously "cancelled"), possibly by changing FormState::setAnyErrors
    4. Investigate where FormState::setAnyErrors is used, and/or git history to see if we can figure out when/why it was made static in the first place.
    5. See how easy it would be to create a new function in FormState to check if validation was completed without errors, and get other code to use it?
  • 🇪🇸Spain tunic Madrid

    @AleexGreen, from what I've understood and from what I've commented I think you have summed up the current state of the issue perfectly.

    Thanks!

  • Pipeline finished with Failed
    about 2 months ago
    Total: 276s
    #160280
  • Pipeline finished with Failed
    about 2 months ago
    Total: 226s
    #160287
  • Pipeline finished with Success
    about 1 month ago
    Total: 612s
    #165914
  • First commit to issue fork.
  • Pipeline finished with Success
    about 1 month ago
    Total: 521s
    #172340
  • Pipeline finished with Failed
    19 days ago
    Total: 1010s
    #185515
  • Pipeline finished with Success
    14 days ago
    Total: 782s
    #190304
  • 🇨🇦Canada AlexGreen

    4. Investigate where FormState::setAnyErrors is used, and/or git history to see if we can figure out when/why it was made static in the first place.

    FormState::setAnyErrors and FormState::hasAnyErrors were created in #2225353: Convert $form_state to an object and provide methods like setError() → between comments 19 and 20 by @tim.plunkett in July 2014 for 8.0.x (i.e.: before a Drupal 8.0 release)
    No explanation given for why they were made static.

    According to Sling Academy guide on PHP static properties

    When you declare class properties […] as static, they are accessible without needing to create an instance of the class. This feature is useful for values […] that are logically intrinsic to the class rather than to any individual object

    Maybe @tim.plunkett intended for “protected static $anyErrors” to be a “flag” that lasts for the duration of the current page request for the current user? (regarding subforms, inline_entity_form existed for D7 at that point, but it’s unclear if that was considered the use-case).

    public static function setAnyErrors() is used in public function setErrorByName() after recording the element with the error and the error message. It’s also used in public function clearErrors() but isn’t defined in an Interface.
    public static function hasAnyErrors() is defined in FormStateInterface, and is used ~11 times across Drupal core (as of commit bcabbbdb75 to 11.x on 2024-06-03)

    1. 3 times in \Drupal\media_library\Form\AddFormBase and 3 times in \Drupal\Core\Form\FormBuilder to abort processing
    2. In \Drupal\Core\Ajax\AjaxFormHelperTrait::ajaxSubmit() to output status messages in an AJAX response
    3. In \Drupal\media_library\Form\FileUploadForm::validateUploadElement to abort processing and force the user to re-upload a file
    4. In \Drupal\Core\Form\FormStateDecoratorBase::hasAnyErrors() to be a backend for its own \Drupal\Core\Form\FormStateDecoratorBase::hasAnyErrors() function
    5. In \Drupal\Tests\Core\Form\FormStateTest::testSetErrorByName() to test setErrorByName()
    6. In \Drupal\form_test\Form\FormTestStoragePageCacheForm::validateForm() to know when to cache the form
Production build 0.69.0 2024