Skip to content

[GFZSPAM-14] Replace static anti-spam key with signed HMAC tokens#52

Open
mrcasual wants to merge 3 commits intomainfrom
feature/GFZSPAM-14-signed-token-anti-spam
Open

[GFZSPAM-14] Replace static anti-spam key with signed HMAC tokens#52
mrcasual wants to merge 3 commits intomainfrom
feature/GFZSPAM-14-signed-token-anti-spam

Conversation

@mrcasual
Copy link
Contributor

@mrcasual mrcasual commented Feb 28, 2026

Summary

  • Replaces the static gf_zero_spam_key (visible in page source, replayable by bots) with HMAC-SHA256 signed tokens fetched at submit time
  • Tokens are signed server-side using AUTH_KEY/SECURE_AUTH_KEY, scoped per form, expire in 10 minutes, and include a random nonce
  • Three-tier token delivery (REST API → admin-ajax → server-rendered fallback) for compatibility with all hosting environments and page caching setups
  • Per-IP rate limiting on the token endpoint (30/min default)
  • 14-day migration window accepts legacy keys from cached pages after upgrading
  • New filters: gf_zero_spam_client_ip, gf_zero_spam_rate_limit

Closes GFZSPAM-14

💾 Build file (78592b7).

Summary by CodeRabbit

  • New Features

    • Time-limited signed tokens for stronger spam prevention (TTL 600s) with rotation-aware validation
    • REST and AJAX endpoints to mint tokens, with per-IP rate limiting (default 30/min) and no-cache responses
    • Dynamic client-side token fetch and migration path from legacy keys, plus admin migration notice and automatic cleanup
  • Documentation

    • Added filters to override client IP for rate limiting and to adjust the rate limit value

Replace the static key mechanism with stateless HMAC-SHA256 signed
tokens that expire and cannot be replayed. Tokens are fetched at
submit time via a three-tier fallback chain (REST API > admin-ajax >
server-rendered token), making the system compatible with page caching.
@linear
Copy link

linear bot commented Feb 28, 2026

@coderabbitai
Copy link

coderabbitai bot commented Feb 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7516730 and 78592b7.

📒 Files selected for processing (1)
  • includes/class-gf-zero-spam.php

Walkthrough

Adds a signed token anti-spam system: new GF_Zero_Spam_Token (mint/validate HMAC-SHA256), a GF_Zero_Spam_Token_Endpoint exposing REST and admin-ajax token minting with per-IP rate limiting, and integrates token validation and migration fallback into GF_Zero_Spam. Updates docs and phpstan ignore rules.

Changes

Cohort / File(s) Summary
Token core
includes/class-gf-zero-spam-token.php
New stateless token utility providing mint and validate, base64url helpers, site-secret derivation, salt-version rotation, and tolerant validation logic.
Token endpoints & rate limiting
includes/class-gf-zero-spam-token-endpoint.php
New endpoint class registering REST route gf-zero-spam/v1/token and admin-ajax gf_zero_spam_token; central handler validates form_id, enforces per-IP rate limits (default 30/min, filterable), mints tokens (TTL 600s), returns token+expires with no-cache headers.
Integration & migration
includes/class-gf-zero-spam.php
Integrates token flow into submission handling and key-field checks; stores gf_zero_spam_token in entries, validates tokens with fallback to legacy key during migration, injects token via JS/REST-AJAX, adds migration notice and legacy cleanup, and clears salt/version options on deactivate.
Static analysis & docs
phpstan.dist.neon, readme.txt
Add phpstan ignore for missing AUTH_KEY/SECURE_AUTH_KEY; changelog entries for signed tokens and new filters gf_zero_spam_client_ip and gf_zero_spam_rate_limit.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Endpoint as Token Endpoint
    participant Token as Token Service
    participant Response

    Client->>Endpoint: GET /gf-zero-spam/v1/token?form_id=ID
    Endpoint->>Endpoint: validate form_id, check feature enabled, check rate limit
    alt Rate limited
        Endpoint->>Response: 429 {error}
    else Allowed
        Endpoint->>Token: mint(form_id, ttl=600)
        Token->>Token: create payload, sign with HMAC-SHA256
        Token->>Endpoint: {token, expires}
        Endpoint->>Response: 200 {token, expires} + no-cache headers
    end
    Response->>Client: deliver token
Loading
sequenceDiagram
    participant User
    participant Form as Form Handler
    participant Validator as Token Validator
    participant Legacy as Legacy Key Handler

    User->>Form: Submit with gf_zero_spam_token (or legacy key)
    Form->>Validator: validate(token, form_id)
    alt Token valid
        Validator->>Form: allow submission
    else Token invalid/absent
        Form->>Legacy: validate_legacy_key(submitted_key)
        alt Within migration window and valid
            Legacy->>Form: allow submission
        else
            Legacy->>Form: reject submission
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Release 1.6.0 #51 — Modifies form injection and client-side key handling; strongly related to this PR's replacement of legacy client-side key flow with token-based injection and endpoints.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and accurately summarizes the main change: replacing static anti-spam keys with signed HMAC tokens, which is the core objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/GFZSPAM-14-signed-token-anti-spam

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
includes/class-gf-zero-spam-token-endpoint.php (1)

193-199: Clamp rate limit filter output to a positive minimum.

If gf_zero_spam_rate_limit returns 0 or negative values, every request gets blocked.

Suggested fix
-		$limit = (int) apply_filters( 'gf_zero_spam_rate_limit', self::RATE_LIMIT );
+		$limit = max( 1, (int) apply_filters( 'gf_zero_spam_rate_limit', self::RATE_LIMIT ) );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@includes/class-gf-zero-spam-token-endpoint.php` around lines 193 - 199, The
rate limit obtained from the gf_zero_spam_rate_limit filter can be zero or
negative which blocks all requests; change the assignment of $limit so it's
clamped to a sensible positive minimum (e.g., 1) by taking the max of 1 and the
filtered integer value (use the existing self::RATE_LIMIT fallback), i.e.,
update the $limit calculation where gf_zero_spam_rate_limit is applied to ensure
$limit = max(1, (int) apply_filters( 'gf_zero_spam_rate_limit', self::RATE_LIMIT
)); so subsequent checks against $count behave correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@includes/class-gf-zero-spam-token-endpoint.php`:
- Around line 99-101: The WP_Error status is being passed as a raw integer to
wp_send_json_error which REST ignores; update the error handling in
class-gf-zero-spam-token-endpoint (where $result is a WP_Error and methods like
get_error_data() / get_error_message() are used) to extract status from
get_error_data() when it's an array with a 'status' key (e.g., $data =
$result->get_error_data(); $status = is_array($data) && isset($data['status']) ?
(int)$data['status'] : (int)$data;) and pass that status into
wp_send_json_error, and also update the AJAX handler branch(s) that read
get_error_data() so they likewise handle array['status'] format instead of
assuming a raw int; apply the same change to the other occurrences referenced
(around the other lines).

In `@includes/class-gf-zero-spam.php`:
- Around line 91-93: Sanitize the incoming token before persisting it: replace
the raw rgpost('gf_zero_spam_token') assignment with a cleaned value using
WordPress sanitization (e.g., $token = sanitize_text_field(
rgpost('gf_zero_spam_token') );) and then store
$submission['partial_entry']['gf_zero_spam_token'] = $token; — update the code
in includes/class-gf-zero-spam.php where the partial_entry is populated so only
the sanitized token is written.
- Around line 196-197: The fallback token TTL is too long: replace the
DAY_IN_SECONDS used in GF_Zero_Spam_Token::mint (where $fallback_token is
created) with a much shorter lifetime (e.g., MINUTE_IN_SECONDS or a
config-driven short constant like FALLBACK_TOKEN_TTL) so the fallback window for
replay protection is minimal; update the call site that mints $fallback_token
and any related tests/config to use the new short TTL and ensure it remains
shorter than the primary token lifetime.
- Around line 461-468: Migration deadline is being set lazily on first legacy
submission in class-gf-zero-spam.php which allows legacy keys to remain valid
indefinitely; instead, add a method (e.g., maybe_initialize_legacy_deadline)
that runs during plugin init/upgrade to set gf_zero_spam_legacy_deadline to
time() + 14 * DAY_IN_SECONDS if the option is missing and gf_zero_spam_key
exists, remove the current lazy initialization block from the legacy handling
code, and call the new method from the plugin's initialization/upgrade routine
(reference the class name and the new maybe_initialize_legacy_deadline method
and the gf_zero_spam_legacy_deadline and gf_zero_spam_key options to locate
where to make the changes).

In `@readme.txt`:
- Line 115: The README's absolute security claim ("tokens can't be copied from
the page source") is incorrect because includes/class-gf-zero-spam.php exposes a
client-side fallbackToken (reference fallbackToken in that file); update the
README wording to soften the claim and accurately describe the protection: state
that tokens are signed/validated server-side and tied to request/context (or
expiry) rather than being impossible to read from page source, and mention that
client-side exposure does not by itself allow successful reuse without the
server-side checks.

---

Nitpick comments:
In `@includes/class-gf-zero-spam-token-endpoint.php`:
- Around line 193-199: The rate limit obtained from the gf_zero_spam_rate_limit
filter can be zero or negative which blocks all requests; change the assignment
of $limit so it's clamped to a sensible positive minimum (e.g., 1) by taking the
max of 1 and the filtered integer value (use the existing self::RATE_LIMIT
fallback), i.e., update the $limit calculation where gf_zero_spam_rate_limit is
applied to ensure $limit = max(1, (int) apply_filters(
'gf_zero_spam_rate_limit', self::RATE_LIMIT )); so subsequent checks against
$count behave correctly.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc37b0b and 0c70eb7.

📒 Files selected for processing (5)
  • includes/class-gf-zero-spam-token-endpoint.php
  • includes/class-gf-zero-spam-token.php
  • includes/class-gf-zero-spam.php
  • phpstan.dist.neon
  • readme.txt

Comment on lines +99 to +101
$status = (int) $result->get_error_data();

wp_send_json_error( $result->get_error_message(), $status );
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

WordPress REST API WP_Error status code format ['status' => code] vs integer data parameter

💡 Result:

In WordPress REST API callbacks, the HTTP status code for a WP_Error is not taken from an integer passed as the error “data”. The REST server determines the response status by looking for an array with a status key in the error’s data:

  • Use: new WP_Error( 'my_code', 'Message', [ 'status' => 404 ] ) (or set error data similarly).
  • If you pass an integer as the data (e.g. new WP_Error(..., 404)), it’s treated as payload data, and the REST API will not use it as the HTTP status—so the response status falls back to a default (commonly 500) unless a ['status' => ...] array is present. This is exactly how rest_convert_error_to_response() extracts the status. [1]
  • The REST API docs explicitly describe customizing WP_Error HTTP status via the status option in the error data array. [2]

If you don’t want WP_Error, you can return a WP_REST_Response( $data, $status_code ) to set status directly. [3]

Sources:
[1] rest_convert_error_to_response() status extraction logic (checks is_array($error_data) and isset($error_data['status'])) https://developer.wordpress.org/reference/functions/rest_convert_error_to_response/
[2] REST API “Adding Custom Endpoints” docs showing WP_Error(..., array('status' => 400)) https://ja.wp-api.org/extending/adding/
[3] WP_REST_Response supports setting HTTP status code https://developer.wordpress.org/plugins/rest-api/responses-2/


🏁 Script executed:

# First, locate and examine the target file
fd "class-gf-zero-spam-token-endpoint.php" --type f

Repository: GravityKit/gravity-forms-zero-spam

Length of output: 124


🏁 Script executed:

# Examine the target file, focusing on the specified line ranges
head -210 "includes/class-gf-zero-spam-token-endpoint.php" | tail -125

Repository: GravityKit/gravity-forms-zero-spam

Length of output: 3499


Use REST-compatible WP_Error status data.

When this endpoint returns WP_Error to the REST API, raw integers as the third parameter are not recognized as HTTP status codes—the REST server only extracts status from ['status' => code] array format, otherwise defaulting to HTTP 500. The AJAX handler also needs updating to extract status from the array format.

Suggested fix
-			$status = (int) $result->get_error_data();
+			$error_data = $result->get_error_data();
+			$status     = (int) ( is_array( $error_data ) && isset( $error_data['status'] ) ? $error_data['status'] : 500 );

 			wp_send_json_error( $result->get_error_message(), $status );
-			return new WP_Error( 'missing_form_id', __( 'A valid form_id is required.', 'gravity-forms-zero-spam' ), 400 );
+			return new WP_Error( 'missing_form_id', __( 'A valid form_id is required.', 'gravity-forms-zero-spam' ), [ 'status' => 400 ] );
-			return new WP_Error( 'invalid_form', __( 'Form not found.', 'gravity-forms-zero-spam' ), 400 );
+			return new WP_Error( 'invalid_form', __( 'Form not found.', 'gravity-forms-zero-spam' ), [ 'status' => 400 ] );
-			return new WP_Error( 'zero_spam_disabled', __( 'Zero Spam is not enabled for this form.', 'gravity-forms-zero-spam' ), 400 );
+			return new WP_Error( 'zero_spam_disabled', __( 'Zero Spam is not enabled for this form.', 'gravity-forms-zero-spam' ), [ 'status' => 400 ] );
-			return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'gravity-forms-zero-spam' ), 429 );
+			return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'gravity-forms-zero-spam' ), [ 'status' => 429 ] );

Also applies to: 118-119, 124-125, 131-132, 196-197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@includes/class-gf-zero-spam-token-endpoint.php` around lines 99 - 101, The
WP_Error status is being passed as a raw integer to wp_send_json_error which
REST ignores; update the error handling in class-gf-zero-spam-token-endpoint
(where $result is a WP_Error and methods like get_error_data() /
get_error_message() are used) to extract status from get_error_data() when it's
an array with a 'status' key (e.g., $data = $result->get_error_data(); $status =
is_array($data) && isset($data['status']) ? (int)$data['status'] : (int)$data;)
and pass that status into wp_send_json_error, and also update the AJAX handler
branch(s) that read get_error_data() so they likewise handle array['status']
format instead of assuming a raw int; apply the same change to the other
occurrences referenced (around the other lines).

Comment on lines +91 to 93
// Store the token in the draft JSON for audit purposes only; not re-validated on resume.
$submission['partial_entry']['gf_zero_spam_token'] = rgpost( 'gf_zero_spam_token' );

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitize token before persisting draft JSON.

Line 92 stores raw request data; sanitize before writing it into partial_entry.

Suggested fix
-		$submission['partial_entry']['gf_zero_spam_token'] = rgpost( 'gf_zero_spam_token' );
+		$submission['partial_entry']['gf_zero_spam_token'] = sanitize_text_field( (string) rgpost( 'gf_zero_spam_token' ) );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Store the token in the draft JSON for audit purposes only; not re-validated on resume.
$submission['partial_entry']['gf_zero_spam_token'] = rgpost( 'gf_zero_spam_token' );
// Store the token in the draft JSON for audit purposes only; not re-validated on resume.
$submission['partial_entry']['gf_zero_spam_token'] = sanitize_text_field( (string) rgpost( 'gf_zero_spam_token' ) );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@includes/class-gf-zero-spam.php` around lines 91 - 93, Sanitize the incoming
token before persisting it: replace the raw rgpost('gf_zero_spam_token')
assignment with a cleaned value using WordPress sanitization (e.g., $token =
sanitize_text_field( rgpost('gf_zero_spam_token') );) and then store
$submission['partial_entry']['gf_zero_spam_token'] = $token; — update the code
in includes/class-gf-zero-spam.php where the partial_entry is populated so only
the sanitized token is written.

Comment on lines +196 to +197
$fallback_token = GF_Zero_Spam_Token::mint( $form_id, DAY_IN_SECONDS );
$rest_url = esc_url( rest_url( 'gf-zero-spam/v1/token' ) );
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback token TTL is too long for anti-replay goals.

Line 196 mints a fallback token for DAY_IN_SECONDS, which re-opens a long replay window when endpoint fetches fail.

Suggested fix
-		$fallback_token = GF_Zero_Spam_Token::mint( $form_id, DAY_IN_SECONDS );
+		$fallback_ttl   = 600;
+		$fallback_token = GF_Zero_Spam_Token::mint( $form_id, $fallback_ttl );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$fallback_token = GF_Zero_Spam_Token::mint( $form_id, DAY_IN_SECONDS );
$rest_url = esc_url( rest_url( 'gf-zero-spam/v1/token' ) );
$fallback_ttl = 600;
$fallback_token = GF_Zero_Spam_Token::mint( $form_id, $fallback_ttl );
$rest_url = esc_url( rest_url( 'gf-zero-spam/v1/token' ) );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@includes/class-gf-zero-spam.php` around lines 196 - 197, The fallback token
TTL is too long: replace the DAY_IN_SECONDS used in GF_Zero_Spam_Token::mint
(where $fallback_token is created) with a much shorter lifetime (e.g.,
MINUTE_IN_SECONDS or a config-driven short constant like FALLBACK_TOKEN_TTL) so
the fallback window for replay protection is minimal; update the call site that
mints $fallback_token and any related tests/config to use the new short TTL and
ensure it remains shorter than the primary token lifetime.

Comment on lines +461 to +468
$deadline = get_option( 'gf_zero_spam_legacy_deadline' );

// Set the migration deadline on first encounter.
if ( false === $deadline ) {
$deadline = time() + ( 14 * DAY_IN_SECONDS );

update_option( 'gf_zero_spam_legacy_deadline', $deadline, false );
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Migration window starts too late (first legacy hit).

The deadline is initialized on first legacy submission, so legacy keys can remain acceptable indefinitely until that first hit.

Suggested direction
-		// Set the migration deadline on first encounter.
-		if ( false === $deadline ) {
-			$deadline = time() + ( 14 * DAY_IN_SECONDS );
-
-			update_option( 'gf_zero_spam_legacy_deadline', $deadline, false );
-		}
+		// Deadline should be initialized during migration bootstrap, not on first legacy request.
+		if ( false === $deadline ) {
+			return __( 'Legacy spam prevention key no longer accepted. Please clear your page cache.', 'gravity-forms-zero-spam' );
+		}
// Add once during plugin init/upgrade flow (outside this method).
private function maybe_initialize_legacy_deadline(): void {
	$deadline = get_option( 'gf_zero_spam_legacy_deadline' );
	$key      = get_option( 'gf_zero_spam_key' );

	if ( false === $deadline && ! empty( $key ) ) {
		update_option( 'gf_zero_spam_legacy_deadline', time() + ( 14 * DAY_IN_SECONDS ), false );
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@includes/class-gf-zero-spam.php` around lines 461 - 468, Migration deadline
is being set lazily on first legacy submission in class-gf-zero-spam.php which
allows legacy keys to remain valid indefinitely; instead, add a method (e.g.,
maybe_initialize_legacy_deadline) that runs during plugin init/upgrade to set
gf_zero_spam_legacy_deadline to time() + 14 * DAY_IN_SECONDS if the option is
missing and gf_zero_spam_key exists, remove the current lazy initialization
block from the legacy handling code, and call the new method from the plugin's
initialization/upgrade routine (reference the class name and the new
maybe_initialize_legacy_deadline method and the gf_zero_spam_legacy_deadline and
gf_zero_spam_key options to locate where to make the changes).


= develop =

* Added: Stronger spam prevention using signed tokens that can't be copied from the page source by bots
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Security claim is currently too absolute.

Line 115 says tokens can’t be copied from page source, but includes/class-gf-zero-spam.php embeds fallbackToken client-side (Line 214), so this should be softened.

Suggested wording
-* Added: Stronger spam prevention using signed tokens that can't be copied from the page source by bots
+* Added: Stronger spam prevention using short-lived signed tokens fetched at submit time, with compatibility fallbacks for restricted environments
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Added: Stronger spam prevention using signed tokens that can't be copied from the page source by bots
* Added: Stronger spam prevention using short-lived signed tokens fetched at submit time, with compatibility fallbacks for restricted environments
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@readme.txt` at line 115, The README's absolute security claim ("tokens can't
be copied from the page source") is incorrect because
includes/class-gf-zero-spam.php exposes a client-side fallbackToken (reference
fallbackToken in that file); update the README wording to soften the claim and
accurately describe the protection: state that tokens are signed/validated
server-side and tied to request/context (or expiry) rather than being impossible
to read from page source, and mention that client-side exposure does not by
itself allow successful reuse without the server-side checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant