From d5eb2ac920bd18555350b9c64a4c80fa174cebdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 09:32:38 +0200 Subject: [PATCH 1/7] Add rate limiting DB table --------- Co-authored-by: Mario Borna Mjertan --- src/Activate.php | 4 +++ src/Db/CreateRateLimitingTable.php | 55 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/Db/CreateRateLimitingTable.php diff --git a/src/Activate.php b/src/Activate.php index 2fe3ee3b7..bc1a6d159 100644 --- a/src/Activate.php +++ b/src/Activate.php @@ -13,6 +13,7 @@ use EightshiftForms\Config\Config; use EightshiftForms\Db\CreateActivityLogsTable; use EightshiftForms\Db\CreateEntriesTable; +use EightshiftForms\Db\CreateRateLimitingTable; use EightshiftForms\Permissions\Permissions; use EightshiftFormsVendor\EightshiftLibs\Plugin\HasActivationInterface; use WP_Role; @@ -44,6 +45,9 @@ public function activate(): void // Create DB table. CreateActivityLogsTable::createTable(); + // Create DB table. + CreateRateLimitingTable::createTable(); + // Do a cleanup. \flush_rewrite_rules(); } diff --git a/src/Db/CreateRateLimitingTable.php b/src/Db/CreateRateLimitingTable.php new file mode 100644 index 000000000..be6cd46a7 --- /dev/null +++ b/src/Db/CreateRateLimitingTable.php @@ -0,0 +1,55 @@ +prefix . self::RATE_LIMITING_TABLE; + + $charsetCollate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$tableName} ( + `log_id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `form_id` int(11) DEFAULT NULL, + `user_token` varchar(256) NOT NULL, + `activity_type` varchar(256) NOT NULL, + `created_at` bigint(20) NOT NULL, + PRIMARY KEY (`log_id`), + KEY `token_time` (`user_token`,`created_at`), + KEY `token_form_time` (`user_token`,`form_id`,`created_at`), + KEY `token_activity_time` (`user_token`,`activity_type`,`created_at`), + KEY `token_form_activity_time` (`user_token`,`form_id`,`activity_type`,`created_at`) + ) $charsetCollate;"; + + \maybe_create_table($tableName, $sql); + } +} From 59a026c91408ddc8b2f369ec6732a3ef5ce9da05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 09:33:20 +0200 Subject: [PATCH 2/7] Update phpstan config --- phpstan.neon.dist | 1 - 1 file changed, 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 238b4354b..32897809c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,6 @@ parameters: scanFiles: - vendor/wp-cli/wp-cli/php/class-wp-cli.php bootstrapFiles: - - %rootDir%/../../php-stubs/wordpress-stubs/wordpress-stubs.php - vendor-prefixed/autoload.php paths: - src/ From b8d467ca7a8c7a0f6a898035415330b48b3a049f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 10:06:41 +0200 Subject: [PATCH 3/7] Add model object for rate limiting entries --------- Co-authored-by: Mario Borna Mjertan --- src/Security/RateLimitingLogEntry.php | 125 ++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/Security/RateLimitingLogEntry.php diff --git a/src/Security/RateLimitingLogEntry.php b/src/Security/RateLimitingLogEntry.php new file mode 100644 index 000000000..6c9782011 --- /dev/null +++ b/src/Security/RateLimitingLogEntry.php @@ -0,0 +1,125 @@ +createdAt) { + $this->createdAt = time(); + } + + if (!$this->formId) { + throw new RuntimeException("Form ID is required to write a rate limiting log entry."); + } + + if (!$this->userToken) { + throw new RuntimeException("User token is required to write a rate limiting log entry."); + } + + if (!$this->activityType) { + throw new RuntimeException("Activity type is required to write a rate limiting log entry."); + } + + if ($this->logId) { + throw new RuntimeException("Log ID must be null to write a new rate limiting log entry."); + } + + $tableName = CreateRateLimitingTable::RATE_LIMITING_TABLE; + + global $wpdb; + $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; + + $query = "INSERT INTO %i (form_id, user_token, activity_type, created_at) VALUES (%d, %s, %s, %d)"; + $wpdb->query($wpdb->prepare($query, $table, $this->formId, $this->userToken, $this->activityType, $this->createdAt)); + } + + public static function find( + string $userToken, + string $activityType, + int $windowDuration + ): array { + $windowStart = time() - $windowDuration; + + global $wpdb; + $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; + + $query = "SELECT * FROM %i WHERE user_token = %s AND activity_type = %s AND created_at >= %d"; + $results = $wpdb->get_results($wpdb->prepare($query, $table, $userToken, $activityType, $windowStart)); + return array_map(function ($result) { + return new RateLimitingLogEntry( + userToken: $result->user_token, + activityType: $result->activity_type, + logId: $result->log_id, + formId: $result->form_id, + createdAt: $result->created_at, + ); + }, $results); + } + + public static function countByFormId( + string $userToken, + int $formId, + int $windowDuration + ): int { + $windowStart = time() - $windowDuration; + + global $wpdb; + $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; + + $query = "SELECT COUNT(*) FROM %i WHERE user_token = %s AND form_id = %d AND created_at >= %d"; + return (int)($wpdb->get_var($wpdb->prepare($query, $table ,$userToken, $formId, $windowStart))); + } + + public static function findAggregatedByActivityType( + string $userToken, + int $windowDuration + ): array { + $windowStart = time() - $windowDuration; + + global $wpdb; + $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; + + $query = "SELECT activity_type, COUNT(*) as count FROM %i WHERE user_token = %s AND created_at >= %d GROUP BY activity_type"; + $results = $wpdb->get_results($wpdb->prepare($table, $query, $userToken, $windowStart)); + return array_map(function ($result) { + return [ + 'activityType' => $result->activity_type, + 'count' => $result->count, + ]; + }, $results); + } + + public static function cleanup(int $windowDuration): void + { + $windowStart = time() - $windowDuration; + + global $wpdb; + $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; + + $query = "DELETE FROM %i WHERE created_at < %d"; + $wpdb->query($wpdb->prepare($query, $table, $windowStart)); + } +} From 6d01b2811f5caf9b01b64565464d453d9e4ce6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 11:04:41 +0200 Subject: [PATCH 4/7] Remove SecurityJob cron in favour of LogEntryCleanupJob --------- Co-authored-by: Mario Borna Mjertan --- src/CronJobs/LogEntryCleanupJob.php | 90 +++++++++++++++++++++++++++++ src/CronJobs/SecurityJob.php | 88 ---------------------------- 2 files changed, 90 insertions(+), 88 deletions(-) create mode 100644 src/CronJobs/LogEntryCleanupJob.php delete mode 100644 src/CronJobs/SecurityJob.php diff --git a/src/CronJobs/LogEntryCleanupJob.php b/src/CronJobs/LogEntryCleanupJob.php new file mode 100644 index 000000000..e66787258 --- /dev/null +++ b/src/CronJobs/LogEntryCleanupJob.php @@ -0,0 +1,90 @@ +cleanupLogEntries(); + } + + /** + * Add job to schedule. + * + * @param array $schedules WP schedules list. + * + * @return array + */ + public function addJobToSchedule(array $schedules): array + { + $schedules['daily'] = [ + 'interval' => \DAY_IN_SECONDS, + 'display' => \esc_html__('Every day at midnight', 'eightshift-forms'), + ]; + + return $schedules; + } + + /** + * Cleans up log entries older than a day. + * + * @return void + */ + public function cleanupLogEntries(): void + { + RateLimitingLogEntry::cleanup(\DAY_IN_SECONDS); + } +} diff --git a/src/CronJobs/SecurityJob.php b/src/CronJobs/SecurityJob.php deleted file mode 100644 index 5808bb98a..000000000 --- a/src/CronJobs/SecurityJob.php +++ /dev/null @@ -1,88 +0,0 @@ - $schedules WP schedules list. - * - * @return array - */ - public function addJobToSchedule(array $schedules): array - { - $schedules['daily'] = [ - 'interval' => \DAY_IN_SECONDS, - 'display' => \esc_html__('Every day at midnight', 'eightshift-forms'), - ]; - - return $schedules; - } - - /** - * Run callback when event is triggered. - * - * @return void - */ - public function getJobCallback() - { - \delete_option(SettingsHelpers::getOptionName(SettingsSecurity::SETTINGS_SECURITY_DATA_KEY)); - } -} From 7af895dece9eb4561341644066a71330b856d610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 11:06:00 +0200 Subject: [PATCH 5/7] Add new RateLimitLogEntry model and related changes This will support a granular rate limiting based on the form ID. We have methods that will write and read from the database, and check if the rate limit was exceeded during request validation. The rate limit can be set up from the settings. --------- Co-authored-by: Mario Borna Mjertan --- src/Security/RateLimitingLogEntry.php | 112 ++++++++++++++++++++------ src/Security/Security.php | 82 +++++++++++-------- src/Security/SecurityInterface.php | 3 +- 3 files changed, 138 insertions(+), 59 deletions(-) diff --git a/src/Security/RateLimitingLogEntry.php b/src/Security/RateLimitingLogEntry.php index 6c9782011..29897a119 100644 --- a/src/Security/RateLimitingLogEntry.php +++ b/src/Security/RateLimitingLogEntry.php @@ -16,20 +16,40 @@ */ class RateLimitingLogEntry { + // This class relies heavily on direct database calls that need to be uncached. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + // We require WP 6.2 for preparing the identifier name. + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder + + /** + * Construct a log entry model object. + * + * @param string $userToken A user-identifying token. Can be anything, but mostly a hashed IP address. + * @param string $activityType The type of activity that is being logged. E.g. submit-calculator. + * @param integer|null $logId A log ID, if used to represent an existing log entry, e.g. as a return value from ::find(). + * @param integer|null $formId The ID of the form that the log entry is associated with. + * @param integer|null $createdAt The timestamp of the log entry creation. + */ public function __construct( public readonly string $userToken, public readonly string $activityType, public readonly ?int $logId = null, public readonly ?int $formId = null, public ?int $createdAt = null, - ) {} - - public function write(): void + ) {} // phpcs:ignore + + /** + * Write the log entry represented by the instance to the database as a new row. + * + * @throws RuntimeException If the form ID, user token or activity type is not set. + * + * @return RateLimitingLogEntry The log entry model object that was written to the database. + */ + public function write(): RateLimitingLogEntry { // Write the log entry to the database. - if (!$this->createdAt) { - $this->createdAt = time(); - } + $createdAt = $this->createdAt ?? \time(); if (!$this->formId) { throw new RuntimeException("Form ID is required to write a rate limiting log entry."); @@ -47,28 +67,44 @@ public function write(): void throw new RuntimeException("Log ID must be null to write a new rate limiting log entry."); } - $tableName = CreateRateLimitingTable::RATE_LIMITING_TABLE; - global $wpdb; $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; - $query = "INSERT INTO %i (form_id, user_token, activity_type, created_at) VALUES (%d, %s, %s, %d)"; - $wpdb->query($wpdb->prepare($query, $table, $this->formId, $this->userToken, $this->activityType, $this->createdAt)); + $wpdb->query($wpdb->prepare("INSERT INTO %i (form_id, user_token, activity_type, created_at) VALUES (%d, %s, %s, %d)", $table, $this->formId, $this->userToken, $this->activityType, $this->createdAt)); + + return new self( + userToken: $this->userToken, + activityType: $this->activityType, + logId: $wpdb->insert_id, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps + formId: $this->formId, + createdAt: $createdAt, + ); } + /** + * Find matching log entries in the database, given a user token, activity type and a time window. + * + * @param string $userToken The user token to seek. + * @param string $activityType The activity type to seek. + * @param integer $windowDuration The time window (from current time) in seconds. + * + * @return array An array of log entries that match the criteria, as RateLimitingLogEntry objects. + */ public static function find( string $userToken, string $activityType, int $windowDuration ): array { - $windowStart = time() - $windowDuration; + $windowStart = \time() - $windowDuration; global $wpdb; $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; - $query = "SELECT * FROM %i WHERE user_token = %s AND activity_type = %s AND created_at >= %d"; - $results = $wpdb->get_results($wpdb->prepare($query, $table, $userToken, $activityType, $windowStart)); - return array_map(function ($result) { + $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE user_token = %s AND activity_type = %s AND created_at >= %d", $table, $userToken, $activityType, $windowStart)); + + return \array_map(static function ($result) { + // We use snake-case in the database column names. + // phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps return new RateLimitingLogEntry( userToken: $result->user_token, activityType: $result->activity_type, @@ -76,50 +112,78 @@ public static function find( formId: $result->form_id, createdAt: $result->created_at, ); + // phpcs:enable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps }, $results); } + /** + * Count the number of log entries in the database, given a user token, form ID and a time window. + * + * @param string $userToken The user token to seek. + * @param integer $formId The form ID to seek. + * @param integer $windowDuration The time window (from current time) in seconds. + * + * @return integer The number of log entries that match the criteria. + */ public static function countByFormId( string $userToken, int $formId, int $windowDuration ): int { - $windowStart = time() - $windowDuration; + $windowStart = \time() - $windowDuration; global $wpdb; $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; - $query = "SELECT COUNT(*) FROM %i WHERE user_token = %s AND form_id = %d AND created_at >= %d"; - return (int)($wpdb->get_var($wpdb->prepare($query, $table ,$userToken, $formId, $windowStart))); + return (int)($wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE user_token = %s AND form_id = %d AND created_at >= %d", $table, $userToken, $formId, $windowStart))); } + /** + * Find aggregated log entries in the database, given a user token and a time window. + * + * @param string $userToken The user token to seek. + * @param integer $windowDuration The time window (from current time) in seconds. + * + * @return array> An array of aggregated log entries that match the criteria. Keys are `activityType` and `count`. + */ public static function findAggregatedByActivityType( string $userToken, int $windowDuration ): array { - $windowStart = time() - $windowDuration; + $windowStart = \time() - $windowDuration; global $wpdb; $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; - $query = "SELECT activity_type, COUNT(*) as count FROM %i WHERE user_token = %s AND created_at >= %d GROUP BY activity_type"; - $results = $wpdb->get_results($wpdb->prepare($table, $query, $userToken, $windowStart)); - return array_map(function ($result) { + $results = $wpdb->get_results($wpdb->prepare("SELECT activity_type, COUNT(*) as count FROM %i WHERE user_token = %s AND created_at >= %d GROUP BY activity_type", $table, $userToken, $windowStart)); + + // phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps + return \array_map(static function ($result) { return [ 'activityType' => $result->activity_type, 'count' => $result->count, ]; + // phpcs:enable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps }, $results); } + /** + * Cleanup old log entries from the database. + * + * @param integer $windowDuration The time window (from current time) in seconds to keep log entries. + * + * @return void + */ public static function cleanup(int $windowDuration): void { - $windowStart = time() - $windowDuration; + $windowStart = \time() - $windowDuration; global $wpdb; $table = $wpdb->prefix . CreateRateLimitingTable::RATE_LIMITING_TABLE; - $query = "DELETE FROM %i WHERE created_at < %d"; - $wpdb->query($wpdb->prepare($query, $table, $windowStart)); + $wpdb->query($wpdb->prepare("DELETE FROM %i WHERE created_at < %d", $table, $windowStart)); } + + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder } diff --git a/src/Security/Security.php b/src/Security/Security.php index 142be7043..87d4b72c1 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -26,23 +26,29 @@ class Security implements SecurityInterface * * @var int */ - public const RATE_LIMIT = 20; + public const int RATE_LIMIT = 20; /** * Time window in seconds * * @var int */ - public const RATE_LIMIT_WINDOW = 60; + public const int RATE_LIMIT_WINDOW = 60; + + /** + * A settings key for granular rate limiting on different forms. + */ + public const string RATE_LIMIT_SETTING_NAME = 'granular-rate-limit'; /** * Detect if the request is valid using rate limiting. * * @param string $formType Form type. + * @param int $formId Form ID. * * @return boolean */ - public function isRequestValid(string $formType): bool + public function isRequestValid(string $formType, int $formId): bool { // Bailout if this feature is not enabled. if (!\apply_filters(SettingsSecurity::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { @@ -60,51 +66,59 @@ public function isRequestValid(string $formType): bool return true; } - $ip = $this->getIpAddress('hash'); + $userToken = $this->getIpAddress('hash'); + $activityType = "submit-$formType"; - // If this is the first iteration of this user just add it to the list. - if (!isset($data[$ip])) { - $data[$ip] = [ - 'count' => 1, - 'time' => $time, - ]; - \update_option($keyName, $data); // No need for unserialize because we are storing array. - return true; - } + $rateLimitingEntry = new RateLimitingLogEntry( + userToken: $userToken, + activityType: $activityType, + formId: $formId, + createdAt: $time, + ); - // Extract user's data. - $user = $data[$ip]; - $timestamp = $user['time'] ?? ''; - $count = $user['count'] ?? 0; + $rateLimitingEntry->write(); + + $window = \intval(SettingsHelpers::getOptionValueWithFallback(SettingsSecurity::SETTINGS_SECURITY_RATE_LIMIT_WINDOW_KEY, (string) self::RATE_LIMIT_WINDOW)); - // Reset the count if the time window has passed. - if (($time - $timestamp) > \intval(SettingsHelpers::getOptionValueWithFallback(SettingsSecurity::SETTINGS_SECURITY_RATE_LIMIT_WINDOW_KEY, (string) self::RATE_LIMIT_WINDOW))) { - unset($data[$ip]); - \update_option($keyName, $data); - return true; - } // Check if the request count exceeds the rate limit. - $rateLimitGeneral = SettingsHelpers::getOptionValueWithFallback(SettingsSecurity::SETTINGS_SECURITY_RATE_LIMIT_KEY, (string) self::RATE_LIMIT); + $rateLimit = SettingsHelpers::getOptionValueWithFallback(SettingsSecurity::SETTINGS_SECURITY_RATE_LIMIT_KEY, (string) self::RATE_LIMIT); $rateLimitCalculator = SettingsHelpers::getOptionValue(SettingsSecurity::SETTINGS_SECURITY_RATE_LIMIT_CALCULATOR_KEY); - // Different rate limit for calculator. - if ($rateLimitCalculator && $formType === SettingsCalculator::SETTINGS_TYPE_KEY) { - $rateLimitGeneral = $rateLimitCalculator; + $calculatorTypeKey = SettingsCalculator::SETTINGS_TYPE_KEY; + + $rateLimitForActivityType = match ($activityType) { + "submit-{$calculatorTypeKey}" => $rateLimitCalculator, + default => $rateLimit, + }; + + $aggregatedActivityByType = RateLimitingLogEntry::findAggregatedByActivityType($userToken, $window); + + $sum = 0; + foreach ($aggregatedActivityByType as $aggregate) { + $sum += $aggregate['count']; + + if ($aggregate['activity_type'] === $activityType && $aggregate['count'] > $rateLimitForActivityType) { + return false; + } } - if ($count >= \intval($rateLimitGeneral)) { + if ($sum > $rateLimit) { return false; } - // Finally update the count and time. - $data[$ip] = [ - 'count' => $count + 1, - 'time' => $time, - ]; + $granularRateLimit = \intval(SettingsHelpers::getSettingValue(Security::RATE_LIMIT_SETTING_NAME, (string)$formId)); - \update_option($keyName, $data); + if ($granularRateLimit <= 0) { + return true; + } + + $activityCountByFormId = RateLimitingLogEntry::countByFormId($userToken, $formId, $window); + + if ($activityCountByFormId > $granularRateLimit) { + return false; + } return true; } diff --git a/src/Security/SecurityInterface.php b/src/Security/SecurityInterface.php index 5b63c70bd..1a14d79f0 100644 --- a/src/Security/SecurityInterface.php +++ b/src/Security/SecurityInterface.php @@ -19,10 +19,11 @@ interface SecurityInterface * Detect if the request is valid using rate limiting. * * @param string $formType Form type. + * @param int $formId Form ID. * * @return boolean */ - public function isRequestValid(string $formType): bool; + public function isRequestValid(string $formType, int $formId): bool; /** * Get users Ip address. From 69b765d1f4f12cbab824cfc1b13ef21d050dd759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 11:09:39 +0200 Subject: [PATCH 6/7] Add the settings for rate limit control --------- Co-authored-by: Mario Borna Mjertan --- src/General/SettingsGeneral.php | 59 +++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/src/General/SettingsGeneral.php b/src/General/SettingsGeneral.php index 02c29a8e3..17a9a1823 100644 --- a/src/General/SettingsGeneral.php +++ b/src/General/SettingsGeneral.php @@ -13,6 +13,7 @@ use EightshiftForms\Helpers\FormsHelper; use EightshiftForms\Helpers\GeneralHelpers; use EightshiftForms\Helpers\SettingsOutputHelpers; +use EightshiftForms\Security\Security; use EightshiftForms\Settings\SettingInterface; use EightshiftForms\Helpers\SettingsHelpers; use EightshiftForms\Hooks\FiltersOutputMock; @@ -28,86 +29,91 @@ class SettingsGeneral implements SettingInterface, ServiceInterface /** * Filter settings key. */ - public const FILTER_SETTINGS_NAME = 'es_forms_settings_general'; + public const string FILTER_SETTINGS_NAME = 'es_forms_settings_general'; /** * Settings key. */ - public const SETTINGS_TYPE_KEY = 'general'; + public const string SETTINGS_TYPE_KEY = 'general'; /** * Redirection Success key for each integration with type prefix. */ - public const SETTINGS_GLOBAL_REDIRECT_SUCCESS_KEY = 'redirection-success'; + public const string SETTINGS_GLOBAL_REDIRECT_SUCCESS_KEY = 'redirection-success'; /** * Tracking event name key. */ - public const SETTINGS_GENERAL_TRACKING_EVENT_NAME_KEY = 'general-tracking-event-name'; + public const string SETTINGS_GENERAL_TRACKING_EVENT_NAME_KEY = 'general-tracking-event-name'; /** * Tracking additional data key. */ - public const SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_KEY = 'general-tracking-additional-data'; - public const SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_SUCCESS_KEY = 'general-tracking-additional-data-success'; - public const SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_ERROR_KEY = 'general-tracking-additional-data-error'; + public const string SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_KEY = 'general-tracking-additional-data'; + public const string SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_SUCCESS_KEY = 'general-tracking-additional-data-success'; + public const string SETTINGS_GENERAL_TRACKING_ADDITIONAL_DATA_ERROR_KEY = 'general-tracking-additional-data-error'; /** * Variation key. */ - public const SETTINGS_VARIATION_KEY = 'variation'; + public const string SETTINGS_VARIATION_KEY = 'variation'; /** * Variation should append on global key. */ - public const SETTINGS_VARIATION_SHOULD_APPEND_ON_GLOBAL_KEY = 'variation-should-append-on-global'; + public const string SETTINGS_VARIATION_SHOULD_APPEND_ON_GLOBAL_KEY = 'variation-should-append-on-global'; /** * Form custom name key. */ - public const SETTINGS_FORM_CUSTOM_NAME_KEY = 'form-custom-name'; + public const string SETTINGS_FORM_CUSTOM_NAME_KEY = 'form-custom-name'; /** * Use single submit key. */ - public const SETTINGS_USE_SINGLE_SUBMIT_KEY = 'use-single-submit'; + public const string SETTINGS_USE_SINGLE_SUBMIT_KEY = 'use-single-submit'; /** * Success redirect url key. */ - public const SETTINGS_SUCCESS_REDIRECT_URL_KEY = 'general-redirection-success'; + public const string SETTINGS_SUCCESS_REDIRECT_URL_KEY = 'general-redirection-success'; /** * Hide global message on success key. */ - public const SETTINGS_HIDE_GLOBAL_MSG_ON_SUCCESS_KEY = 'hide-global-msg-on-success'; + public const string SETTINGS_HIDE_GLOBAL_MSG_ON_SUCCESS_KEY = 'hide-global-msg-on-success'; /** * Hide form on success key. */ - public const SETTINGS_HIDE_FORM_ON_SUCCESS_KEY = 'hide-form-on-success'; + public const string SETTINGS_HIDE_FORM_ON_SUCCESS_KEY = 'hide-form-on-success'; /** * Skip reset form on success key. */ - public const SETTINGS_SKIP_RESET_FORM_ON_SUCCESS_KEY = 'skip-reset-form-on-success'; + public const string SETTINGS_SKIP_RESET_FORM_ON_SUCCESS_KEY = 'skip-reset-form-on-success'; /** * Increment meta key. * * @var string */ - public const INCREMENT_META_KEY = 'es_forms_increment'; + public const string INCREMENT_META_KEY = 'es_forms_increment'; /** * Increment start key. */ - public const SETTINGS_INCREMENT_START_KEY = 'increment-start'; + public const string SETTINGS_INCREMENT_START_KEY = 'increment-start'; /** * Increment length key. */ - public const SETTINGS_INCREMENT_LENGTH_KEY = 'increment-length'; + public const string SETTINGS_INCREMENT_LENGTH_KEY = 'increment-length'; + + /** + * Granular rate limit for a particular form. + */ + public const string SETTINGS_RATE_LIMIT_KEY = 'rate-limit'; /** * Register all the hooks @@ -144,6 +150,9 @@ static function ($item, $key) { $trackingEventName = FiltersOutputMock::getTrackingEventNameFilterValue($formType, $formId); $trackingAdditionalData = FiltersOutputMock::getTrackingAdditionalDataFilterValue($formType, $formId); + $rateLimit = \intval(SettingsHelpers::getSettingValue(Security::RATE_LIMIT_SETTING_NAME, $formId)); + $rateLimit = ($rateLimit > 0) ? $rateLimit : ''; + return [ SettingsOutputHelpers::getIntro(self::SETTINGS_TYPE_KEY), [ @@ -209,6 +218,20 @@ static function ($item, $key) { 'component' => 'divider', 'dividerExtraVSpacing' => true, ], + [ + 'component' => 'input', + 'inputName' => SettingsHelpers::getSettingName(Security::RATE_LIMIT_SETTING_NAME), + 'inputFieldLabel' => \__('Rate limit (submission attempts / seconds)', 'eightshift-forms'), + // translators: %s will be replaced with forms field name and filter output copy. + 'inputFieldHelp' => \__('If set, the form will be rate limited based on the provided value, in addition to global rate limits.', 'eightshift-forms'), + 'inputType' => 'number', + 'inputIsDisabled' => false, + 'inputValue' => $rateLimit, + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], [ 'component' => 'checkboxes', 'checkboxesFieldLabel' => '', From 7f48743f373f2f6d24374fdbcf64432923ae0d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 22 Sep 2025 11:10:17 +0200 Subject: [PATCH 7/7] Add form ID to request validation Also cleaned the unused class imports in the Validator.php file. --------- Co-authored-by: Mario Borna Mjertan --- src/Rest/Routes/AbstractIntegrationFormSubmit.php | 2 +- src/Validation/Validator.php | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Rest/Routes/AbstractIntegrationFormSubmit.php b/src/Rest/Routes/AbstractIntegrationFormSubmit.php index 31db52a8d..1559c7a73 100644 --- a/src/Rest/Routes/AbstractIntegrationFormSubmit.php +++ b/src/Rest/Routes/AbstractIntegrationFormSubmit.php @@ -189,7 +189,7 @@ public function routeCallback(WP_REST_Request $request) // Validate allowed number of requests. if ($this->shouldCheckSecurity()) { - if (!$this->getSecurity()->isRequestValid($formDetails[Config::FD_TYPE])) { + if (!$this->getSecurity()->isRequestValid($formDetails[Config::FD_TYPE], $formDetails[Config::FD_FORM_ID])) { // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped throw new RequestLimitException( $this->getLabels()->getLabel('validationSecurity'), diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index ce99abc31..2ffcf674f 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -14,14 +14,6 @@ use EightshiftForms\Form\AbstractFormBuilder; use EightshiftForms\Helpers\GeneralHelpers; use EightshiftForms\Helpers\UploadHelpers; -use EightshiftForms\Integrations\Airtable\SettingsAirtable; -use EightshiftForms\Integrations\Calculator\SettingsCalculator; -use EightshiftForms\Integrations\Corvus\SettingsCorvus; -use EightshiftForms\Integrations\Jira\SettingsJira; -use EightshiftForms\Integrations\Mailer\SettingsMailer; -use EightshiftForms\Integrations\Nationbuilder\SettingsNationbuilder; -use EightshiftForms\Integrations\Paycek\SettingsPaycek; -use EightshiftForms\Integrations\Pipedrive\SettingsPipedrive; use EightshiftForms\Labels\LabelsInterface; use EightshiftForms\Helpers\SettingsHelpers; use EightshiftForms\Config\Config;