Make WordPress Core


Ignore:
Timestamp:
02/21/2023 01:43:33 AM (22 months ago)
Author:
peterwilsoncc
Message:

Comments: Prevent replying to unapproved comments.

Introduces client and server side validation to ensure the replytocom query string parameter can not be exploited to reply to an unapproved comment or display the name of an unapproved commenter.

This only affects commenting via the front end of the site. Comment replies via the dashboard continue their current behaviour of logging the reply and approving the parent comment.

Introduces the $post parameter, defaulting to the current global post, to get_cancel_comment_reply_link() and comment_form_title().

Introduces _get_comment_reply_id() for determining the comment reply ID based on the replytocom query string parameter.

Renames the parameter $post_id to $post in get_comment_id_fields() and comment_id_fields() to accept either a post ID or WP_Post object.

Adds a new WP_Error return state to wp_handle_comment_submission() to prevent replies to unapproved comments. The error code is comment_reply_to_unapproved_comment with the message Sorry, replies to unapproved comments are not allowed..

Props costdev, jrf, hellofromtonya, fasuto, boniu91, milana_cap.
Fixes #53962.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/tests/phpunit/tests/comment.php

    r55321 r55369  
    376376
    377377        $this->assertSame( array(), $found );
     378    }
     379
     380    /**
     381     * Tests that get_cancel_comment_reply_link() returns the expected value.
     382     *
     383     * @ticket 53962
     384     *
     385     * @dataProvider data_get_cancel_comment_reply_link
     386     *
     387     * @covers ::get_cancel_comment_reply_link
     388     *
     389     * @param string        $text       Text to display for cancel reply link.
     390     *                                  If empty, defaults to 'Click here to cancel reply'.
     391     * @param string|int    $post       The post the comment thread is being displayed for.
     392     *                                  Accepts 'POST_ID', 'POST', or an integer post ID.
     393     * @param int|bool|null $replytocom A comment ID (int), whether to generate an approved (true) or unapproved (false) comment,
     394     *                                  or null not to create a comment.
     395     * @param string        $expected   The expected reply link.
     396     */
     397    public function test_get_cancel_comment_reply_link( $text, $post, $replytocom, $expected ) {
     398        if ( 'POST_ID' === $post ) {
     399            $post = self::$post_id;
     400        } elseif ( 'POST' === $post ) {
     401            $post = self::factory()->post->get_object_by_id( self::$post_id );
     402        }
     403
     404        if ( null === $replytocom ) {
     405            unset( $_GET['replytocom'] );
     406        } else {
     407            $_GET['replytocom'] = $this->create_comment_with_approval_status( $replytocom );
     408        }
     409
     410        $this->assertSame( $expected, get_cancel_comment_reply_link( $text, $post ) );
     411    }
     412
     413    /**
     414     * Data provider.
     415     *
     416     * @return array[]
     417     */
     418    public function data_get_cancel_comment_reply_link() {
     419        return array(
     420            'text as empty string, a valid post ID and an approved comment'    => array(
     421                'text'       => '',
     422                'post'       => 'POST_ID',
     423                'replytocom' => true,
     424                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond">Click here to cancel reply.</a>',
     425            ),
     426            'text as a custom string, a valid post ID and an approved comment' => array(
     427                'text'       => 'Leave a reply!',
     428                'post'       => 'POST_ID',
     429                'replytocom' => true,
     430                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond">Leave a reply!</a>',
     431            ),
     432            'text as empty string, a valid WP_Post object and an approved comment' => array(
     433                'text'       => '',
     434                'post'       => 'POST',
     435                'replytocom' => true,
     436                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond">Click here to cancel reply.</a>',
     437            ),
     438            'text as a custom string, a valid WP_Post object and an approved comment' => array(
     439                'text'       => 'Leave a reply!',
     440                'post'       => 'POST',
     441                'replytocom' => true,
     442                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond">Leave a reply!</a>',
     443            ),
     444            'text as empty string, an invalid post and an approved comment'    => array(
     445                'text'       => '',
     446                'post'       => -99999,
     447                'replytocom' => true,
     448                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond" style="display:none;">Click here to cancel reply.</a>',
     449            ),
     450            'text as a custom string, a valid post, but no replytocom' => array(
     451                'text'       => 'Leave a reply!',
     452                'post'       => 'POST',
     453                'replytocom' => null,
     454                'expected'   => '<a rel="nofollow" id="cancel-comment-reply-link" href="#respond" style="display:none;">Leave a reply!</a>',
     455            ),
     456        );
     457    }
     458
     459    /**
     460     * Tests that comment_form_title() outputs the author of an approved comment.
     461     *
     462     * @ticket 53962
     463     *
     464     * @covers ::comment_form_title
     465     */
     466    public function test_should_output_the_author_of_an_approved_comment() {
     467        // Must be set for `comment_form_title()`.
     468        $_GET['replytocom'] = $this->create_comment_with_approval_status( true );
     469
     470        $comment = get_comment( $_GET['replytocom'] );
     471        comment_form_title( false, false, false, self::$post_id );
     472
     473        $this->assertInstanceOf(
     474            'WP_Comment',
     475            $comment,
     476            'The comment is not an instance of WP_Comment.'
     477        );
     478
     479        $this->assertObjectHasAttribute(
     480            'comment_author',
     481            $comment,
     482            'The comment object does not have a "comment_author" property.'
     483        );
     484
     485        $this->assertIsString(
     486            $comment->comment_author,
     487            'The "comment_author" is not a string.'
     488        );
     489
     490        $this->expectOutputString(
     491            'Leave a Reply to ' . $comment->comment_author,
     492            'The expected string was not output.'
     493        );
     494    }
     495
     496    /**
     497     * Tests that get_comment_id_fields() allows replying to an approved comment.
     498     *
     499     * @ticket 53962
     500     *
     501     * @dataProvider data_should_allow_reply_to_an_approved_comment
     502     *
     503     * @covers ::get_comment_id_fields
     504     *
     505     * @param string $comment_post The post of the comment.
     506     *                             Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'.
     507     */
     508    public function test_should_allow_reply_to_an_approved_comment( $comment_post ) {
     509        // Must be set for `get_comment_id_fields()`.
     510        $_GET['replytocom'] = $this->create_comment_with_approval_status( true );
     511
     512        if ( 'POST_ID' === $comment_post ) {
     513            $comment_post = self::$post_id;
     514        } elseif ( 'POST' === $comment_post ) {
     515            $comment_post = self::factory()->post->get_object_by_id( self::$post_id );
     516        }
     517
     518        $expected  = "<input type='hidden' name='comment_post_ID' value='" . self::$post_id . "' id='comment_post_ID' />\n";
     519        $expected .= "<input type='hidden' name='comment_parent' id='comment_parent' value='" . $_GET['replytocom'] . "' />\n";
     520        $actual    = get_comment_id_fields( $comment_post );
     521
     522        $this->assertSame( $expected, $actual );
     523    }
     524
     525    /**
     526     * Data provider.
     527     *
     528     * @return array[]
     529     */
     530    public function data_should_allow_reply_to_an_approved_comment() {
     531        return array(
     532            'a post ID'        => array( 'comment_post' => 'POST_ID' ),
     533            'a WP_Post object' => array( 'comment_post' => 'POST' ),
     534        );
     535    }
     536
     537    /**
     538     * Tests that get_comment_id_fields() returns an empty string
     539     * when the post cannot be retrieved.
     540     *
     541     * @ticket 53962
     542     *
     543     * @dataProvider data_non_existent_posts
     544     *
     545     * @covers ::get_comment_id_fields
     546     *
     547     * @param bool  $replytocom   Whether to create an approved (true) or unapproved (false) comment.
     548     * @param int   $comment_post The post of the comment.
     549     *
     550     */
     551    public function test_should_return_empty_string( $replytocom, $comment_post ) {
     552        if ( is_bool( $replytocom ) ) {
     553            $replytocom = $this->create_comment_with_approval_status( $replytocom );
     554        }
     555
     556        // Must be set for `get_comment_id_fields()`.
     557        $_GET['replytocom'] = $replytocom;
     558
     559        $actual = get_comment_id_fields( $comment_post );
     560
     561        $this->assertSame( '', $actual );
     562    }
     563
     564    /**
     565     * Tests that comment_form_title() does not output the author.
     566     *
     567     * @ticket 53962
     568     *
     569     * @covers ::comment_form_title
     570     *
     571     * @dataProvider data_parent_comments
     572     * @dataProvider data_non_existent_posts
     573     *
     574     * @param bool   $replytocom   Whether to create an approved (true) or unapproved (false) comment.
     575     * @param string $comment_post The post of the comment.
     576     *                             Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'.
     577     */
     578    public function test_should_not_output_the_author( $replytocom, $comment_post ) {
     579        if ( is_bool( $replytocom ) ) {
     580            $replytocom = $this->create_comment_with_approval_status( $replytocom );
     581        }
     582
     583        // Must be set for `comment_form_title()`.
     584        $_GET['replytocom'] = $replytocom;
     585
     586        if ( 'NEW_POST_ID' === $comment_post ) {
     587            $comment_post = self::factory()->post->create();
     588        } elseif ( 'NEW_POST' === $comment_post ) {
     589            $comment_post = self::factory()->post->create_and_get();
     590        } elseif ( 'POST_ID' === $comment_post ) {
     591            $comment_post = self::$post_id;
     592        } elseif ( 'POST' === $comment_post ) {
     593            $comment_post = self::factory()->post->get_object_by_id( self::$post_id );
     594        }
     595
     596        $comment_post_id = $comment_post instanceof WP_Post ? $comment_post->ID : $comment_post;
     597
     598        get_comment( $_GET['replytocom'] );
     599
     600        comment_form_title( false, false, false, $comment_post_id );
     601
     602        $this->expectOutputString( 'Leave a Reply' );
     603    }
     604
     605    /**
     606     * Data provider.
     607     *
     608     * @return array[]
     609     */
     610    public function data_non_existent_posts() {
     611        return array(
     612            'an unapproved comment and a non-existent post ID' => array(
     613                'replytocom'   => false,
     614                'comment_post' => -99999,
     615            ),
     616            'an approved comment and a non-existent post ID' => array(
     617                'replytocom'   => true,
     618                'comment_post' => -99999,
     619            ),
     620        );
     621    }
     622
     623    /**
     624     * Tests that get_comment_id_fields() does not allow replies when
     625     * the comment does not have a parent post.
     626     *
     627     * @ticket 53962
     628     *
     629     * @covers ::get_comment_id_fields
     630     *
     631     * @dataProvider data_parent_comments
     632     *
     633     * @param mixed  $replytocom   Whether to create an approved (true) or unapproved (false) comment,
     634     *                             or an invalid comment ID.
     635     * @param string $comment_post The post of the comment.
     636     *                             Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'.
     637     */
     638    public function test_should_not_allow_reply( $replytocom, $comment_post ) {
     639        if ( is_bool( $replytocom ) ) {
     640            $replytocom = $this->create_comment_with_approval_status( $replytocom );
     641        }
     642
     643        // Must be set for `get_comment_id_fields()`.
     644        $_GET['replytocom'] = $replytocom;
     645
     646        if ( 'NEW_POST_ID' === $comment_post ) {
     647            $comment_post = self::factory()->post->create();
     648        } elseif ( 'NEW_POST' === $comment_post ) {
     649            $comment_post = self::factory()->post->create_and_get();
     650        } elseif ( 'POST_ID' === $comment_post ) {
     651            $comment_post = self::$post_id;
     652        } elseif ( 'POST' === $comment_post ) {
     653            $comment_post = self::factory()->post->get_object_by_id( self::$post_id );
     654        }
     655
     656        $comment_post_id = $comment_post instanceof WP_Post ? $comment_post->ID : $comment_post;
     657
     658        $expected  = "<input type='hidden' name='comment_post_ID' value='" . $comment_post_id . "' id='comment_post_ID' />\n";
     659        $expected .= "<input type='hidden' name='comment_parent' id='comment_parent' value='0' />\n";
     660        $actual    = get_comment_id_fields( $comment_post );
     661
     662        $this->assertSame( $expected, $actual );
     663    }
     664
     665    /**
     666     * Data provider.
     667     *
     668     * @return array[]
     669     */
     670    public function data_parent_comments() {
     671        return array(
     672            'an unapproved parent comment (ID)'      => array(
     673                'replytocom'   => false,
     674                'comment_post' => 'POST_ID',
     675            ),
     676            'an approved parent comment on another post (ID)' => array(
     677                'replytocom'   => true,
     678                'comment_post' => 'NEW_POST_ID',
     679            ),
     680            'an unapproved parent comment on another post (ID)' => array(
     681                'replytocom'   => false,
     682                'comment_post' => 'NEW_POST_ID',
     683            ),
     684            'a parent comment ID that cannot be cast to an integer' => array(
     685                'replytocom'   => array( 'I cannot be cast to an integer.' ),
     686                'comment_post' => 'POST_ID',
     687            ),
     688            'an unapproved parent comment (WP_Post)' => array(
     689                'replytocom'   => false,
     690                'comment_post' => 'POST',
     691            ),
     692            'an approved parent comment on another post (WP_Post)' => array(
     693                'replytocom'   => true,
     694                'comment_post' => 'NEW_POST',
     695            ),
     696            'an unapproved parent comment on another post (WP_Post)' => array(
     697                'replytocom'   => false,
     698                'comment_post' => 'NEW_POST',
     699            ),
     700            'a parent comment WP_Post that cannot be cast to an integer' => array(
     701                'replytocom'   => array( 'I cannot be cast to an integer.' ),
     702                'comment_post' => 'POST',
     703            ),
     704        );
     705    }
     706
     707    /**
     708     * Helper function to create a comment with an approval status.
     709     *
     710     * @since 6.2.0
     711     *
     712     * @param bool $approved Whether or not the comment is approved.
     713     * @return int The comment ID.
     714     */
     715    public function create_comment_with_approval_status( $approved ) {
     716        return self::factory()->comment->create(
     717            array(
     718                'comment_post_ID'  => self::$post_id,
     719                'comment_approved' => ( $approved ) ? '1' : '0',
     720            )
     721        );
     722    }
     723
     724    /**
     725     * Tests that _get_comment_reply_id() returns the expected value.
     726     *
     727     * @ticket 53962
     728     *
     729     * @dataProvider data_get_comment_reply_id
     730     *
     731     * @covers ::_get_comment_reply_id
     732     *
     733     * @param int|bool|null $replytocom A comment ID (int), whether to generate an approved (true) or unapproved (false) comment,
     734     *                                  or null not to create a comment.
     735     * @param string|int    $post       The post the comment thread is being displayed for.
     736     *                                  Accepts 'POST_ID', 'POST', or an integer post ID.
     737     * @param int           $expected   The expected result.
     738     */
     739    public function test_get_comment_reply_id( $replytocom, $post, $expected ) {
     740        if ( false === $replytocom ) {
     741            unset( $_GET['replytocom'] );
     742        } else {
     743            $_GET['replytocom'] = $this->create_comment_with_approval_status( (bool) $replytocom );
     744        }
     745
     746        if ( 'POST_ID' === $post ) {
     747            $post = self::$post_id;
     748        } elseif ( 'POST' === $post ) {
     749            $post = self::factory()->post->get_object_by_id( self::$post_id );
     750        }
     751
     752        if ( 'replytocom' === $expected ) {
     753            $expected = $_GET['replytocom'];
     754        }
     755
     756        $this->assertSame( $expected, _get_comment_reply_id( $post ) );
     757    }
     758
     759    /**
     760     * Data provider.
     761     *
     762     * @return array[]
     763     */
     764    public function data_get_comment_reply_id() {
     765        return array(
     766            'no comment ID set ($_GET["replytocom"])'     => array(
     767                'replytocom' => false,
     768                'post'       => 0,
     769                'expected'   => 0,
     770            ),
     771            'a non-numeric comment ID'                    => array(
     772                'replytocom' => 'three',
     773                'post'       => 0,
     774                'expected'   => 0,
     775            ),
     776            'a non-existent comment ID'                   => array(
     777                'replytocom' => -999999,
     778                'post'       => 0,
     779                'expected'   => 0,
     780            ),
     781            'an unapproved comment'                       => array(
     782                'replytocom' => false,
     783                'post'       => 0,
     784                'expected'   => 0,
     785            ),
     786            'a post that does not match the parent'       => array(
     787                'replytocom' => false,
     788                'post'       => -999999,
     789                'expected'   => 0,
     790            ),
     791            'an approved comment and the correct post ID' => array(
     792                'replytocom' => true,
     793                'post'       => 'POST_ID',
     794                'expected'   => 'replytocom',
     795            ),
     796            'an approved comment and the correct WP_Post object' => array(
     797                'replytocom' => true,
     798                'post'       => 'POST',
     799                'expected'   => 'replytocom',
     800            ),
     801        );
    378802    }
    379803
Note: See TracChangeset for help on using the changeset viewer.