Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions phpunit-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
*/
require_once( WP_PLUGIN_DIR . '/wordcamp-organizer-reminders/tests/bootstrap.php' );
require_once WP_PLUGIN_DIR . '/wcpt/tests/bootstrap.php';
require_once WP_PLUGIN_DIR . '/wordcamp-forms-to-drafts/tests/bootstrap.php';
require_once( WP_PLUGIN_DIR . '/wordcamp-remote-css/tests/bootstrap.php' );
require_once WP_PLUGIN_DIR . '/wordcamp-speaker-feedback/tests/bootstrap.php';
require_once WP_PLUGIN_DIR . '/wordcamp-payments-network/tests/bootstrap.php';
Expand Down
7 changes: 7 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
</directory>
</testsuite>

<testsuite name="WordCamp Forms to Drafts">
<directory prefix="test-" suffix=".php">
./public_html/wp-content/plugins/wordcamp-forms-to-drafts/tests/
</directory>
</testsuite>

<testsuite name="WordCamp Remote CSS">
<directory prefix="test-" suffix=".php">
./public_html/wp-content/plugins/wordcamp-remote-css/tests/
Expand All @@ -69,6 +75,7 @@
<directory suffix=".php">./public_html/wp-content/plugins/camptix</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wc-post-types</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wcpt</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wordcamp-forms-to-drafts</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wordcamp-organizer-reminders</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wordcamp-payments/</directory>
<directory suffix=".php">./public_html/wp-content/plugins/wordcamp-payments-network/</directory>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace WordCamp\Forms_To_Drafts\Tests;

if ( 'cli' !== php_sapi_name() ) {
return;
}

$core_tests_directory = getenv( 'WP_TESTS_DIR' );

if ( ! $core_tests_directory ) {
echo "\nPlease set the WP_TESTS_DIR environment variable to the folder where WordPress' PHPUnit tests live --";
echo "\ne.g., export WP_TESTS_DIR=/srv/www/wordpress-develop/tests/phpunit\n";

return;
}

require_once $core_tests_directory . '/includes/functions.php';

/**
* Load the plugin and its dependencies.
*/
function manually_load_plugin() {
// Load the utilities autoloader so Form_Spam_Prevention is available.
require_once SUT_WPMU_PLUGIN_DIR . '/2-autoloader.php';

require_once dirname( __DIR__ ) . '/wordcamp-forms-to-drafts.php';
}
tests_add_filter( 'muplugins_loaded', __NAMESPACE__ . '\manually_load_plugin' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

defined( 'WPINC' ) || die();

/**
* Tests for IP-based rate limiting in WordCamp_Forms_To_Drafts.
*
* @covers WordCamp_Forms_To_Drafts::rate_limit_submissions
*/
class Test_Rate_Limiting extends WP_UnitTestCase {

/**
* @var WordCamp_Forms_To_Drafts
*/
protected static $plugin;

/**
* Set up shared fixtures before any tests run.
*
* @param WP_UnitTest_Factory $factory Test factory.
*/
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$plugin = $GLOBALS['wordcamp_forms_to_drafts'];
}

/**
* Clean up after each test.
*/
public function tear_down() {
// Clear any rate limit transients set during tests.
// The key format is: form-spam-prevention-wcfd-{md5(ip)}.
delete_transient( 'form-spam-prevention-wcfd-' . md5( '127.0.0.1' ) );

// Reset to logged-out state.
wp_set_current_user( 0 );

// Reset REMOTE_ADDR.
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

parent::tear_down();
}

/**
* Verify logged-in users bypass rate limiting entirely.
*/
public function test_logged_in_user_bypasses_rate_limiting() {
$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $user_id );

$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

$result = self::$plugin->rate_limit_submissions( false );

$this->assertFalse( $result );
}

/**
* Verify already-spam submissions pass through unchanged.
*/
public function test_already_spam_passes_through() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

$error = new WP_Error( 'spam', 'Already spam' );
$result = self::$plugin->rate_limit_submissions( $error );

$this->assertWPError( $result );
$this->assertSame( 'spam', $result->get_error_code() );
}

/**
* Verify truthy non-WP_Error spam value passes through.
*/
public function test_truthy_spam_value_passes_through() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

$result = self::$plugin->rate_limit_submissions( true );

$this->assertTrue( $result );
}

/**
* Verify the first few submissions succeed for logged-out users.
*
* With score_threshold=4 and each submission adding 1 point,
* submissions 1-3 should succeed (scores 1, 2, 3 -- all below threshold).
*/
public function test_first_submissions_succeed() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

// First submission: score goes to 1.
$result1 = self::$plugin->rate_limit_submissions( false );
$this->assertFalse( $result1, 'First submission should succeed.' );

// Second submission: score goes to 2.
$result2 = self::$plugin->rate_limit_submissions( false );
$this->assertFalse( $result2, 'Second submission should succeed.' );

// Third submission: score goes to 3.
$result3 = self::$plugin->rate_limit_submissions( false );
$this->assertFalse( $result3, 'Third submission should succeed.' );
}

/**
* Verify the 4th submission returns WP_Error for logged-out users.
*
* After 3 successful submissions the score is 3. The 4th call checks
* is_ip_address_throttled() which returns true when score >= 4, but
* at the start of the 4th call the score is 3 (not yet >= 4), so
* the 4th call also adds 1 (score becomes 4). The 5th call sees
* score=4 >= threshold=4 and returns WP_Error.
*/
public function test_rate_limited_after_threshold() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

// Make 4 submissions to reach score of 4.
for ( $i = 0; $i < 4; $i++ ) {
self::$plugin->rate_limit_submissions( false );
}

// 5th submission should be rate limited (score is now 4, which >= threshold).
$result = self::$plugin->rate_limit_submissions( false );

$this->assertWPError( $result );
$this->assertSame( 'rate_limited', $result->get_error_code() );
}

/**
* Verify rate limit resets after the transient is cleared.
*
* In production the transient expires after HOUR_IN_SECONDS.
* We simulate this by deleting the transient directly.
*/
public function test_rate_limit_resets_after_transient_expires() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

// Exceed the threshold.
for ( $i = 0; $i < 4; $i++ ) {
self::$plugin->rate_limit_submissions( false );
}

// Confirm rate limited.
$result = self::$plugin->rate_limit_submissions( false );
$this->assertWPError( $result );

// Simulate transient expiry.
delete_transient( 'form-spam-prevention-wcfd-' . md5( '127.0.0.1' ) );

// Should succeed again after reset.
$result = self::$plugin->rate_limit_submissions( false );
$this->assertFalse( $result, 'Submission should succeed after transient expires.' );
}

/**
* Verify logged-in users bypass rate limiting even when IP is already throttled.
*/
public function test_logged_in_user_bypasses_even_when_throttled() {
wp_set_current_user( 0 );
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

// Exceed the threshold as logged-out user.
for ( $i = 0; $i < 4; $i++ ) {
self::$plugin->rate_limit_submissions( false );
}

// Log in.
$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $user_id );

// Should bypass even though IP is throttled.
$result = self::$plugin->rate_limit_submissions( false );
$this->assertFalse( $result );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,26 @@
}

class WordCamp_Forms_To_Drafts {
/**
* @var \WordCamp\Utilities\Form_Spam_Prevention
*/
protected $spam_prevention;

/**
* Constructor
*/
public function __construct() {
$this->spam_prevention = new \WordCamp\Utilities\Form_Spam_Prevention( array(
'score_threshold' => 4,
'throttle_duration' => HOUR_IN_SECONDS,
'prefix' => 'wcfd-',
) );
add_action( 'wp_print_styles', array( $this, 'print_front_end_styles' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_inert_script' ) );
add_filter( 'the_content', array( $this, 'force_login_to_use_form' ), 8 );
add_action( 'template_redirect', array( $this, 'populate_form_based_on_user' ), 9 );
add_Action( 'jetpack_contact_form_is_spam', array( $this, 'prevent_form_submission' ) );
add_filter( 'jetpack_contact_form_is_spam', array( $this, 'rate_limit_submissions' ), 20 );
add_action( 'grunion_pre_message_sent', array( $this, 'call_for_sponsors' ), 10, 3 );
add_action( 'grunion_pre_message_sent', array( $this, 'call_for_speakers' ), 10, 3 );
add_action( 'grunion_pre_message_sent', array( $this, 'call_for_volunteers' ), 10, 3 );
Expand Down Expand Up @@ -229,6 +240,36 @@ public function prevent_form_submission( $is_spam ) {
return $is_spam;
}

/**
* Rate limit form submissions for non-logged-in users.
*
* Pentesters and bots can spam forms with thousands of submissions, creating
* excessive draft posts. Uses the existing Form_Spam_Prevention utility to
* throttle by IP address.
*
* @param bool|WP_Error $is_spam Whether the submission is spam.
*
* @return bool|WP_Error
*/
public function rate_limit_submissions( $is_spam ) {
// Already marked as spam, or user is logged in.
if ( $is_spam || is_user_logged_in() ) {
return $is_spam;
}

if ( $this->spam_prevention->is_ip_address_throttled() ) {
return new WP_Error(
'rate_limited',
__( 'You have submitted too many forms recently. Please wait a while and try again, or log in to your WordPress.org account.', 'wordcamporg' )
);
}

// Each submission adds 1 point. With a threshold of 4, this allows ~3 submissions per hour.
$this->spam_prevention->add_score_to_ip_address( array( 1 ) );

return $is_spam;
}

/**
* Get the Grunion field ID
*
Expand Down
Loading