Skip to content

Commit 14436c2

Browse files
authored
Merge pull request #52 from GravityKit/feature/GFZSPAM-14-signed-token-anti-spam
2 parents 0c323fa + 78592b7 commit 14436c2

5 files changed

Lines changed: 704 additions & 32 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<?php
2+
/**
3+
* REST API and admin-ajax endpoints for token minting.
4+
*
5+
* @since TBD
6+
*/
7+
8+
if ( ! defined( 'WPINC' ) ) {
9+
die;
10+
}
11+
12+
class GF_Zero_Spam_Token_Endpoint {
13+
14+
/**
15+
* Maximum token requests per IP per minute.
16+
*
17+
* @since TBD
18+
*
19+
* @var int
20+
*/
21+
const RATE_LIMIT = 30;
22+
23+
/**
24+
* REST API namespace.
25+
*
26+
* @since TBD
27+
*
28+
* @var string
29+
*/
30+
const REST_NAMESPACE = 'gf-zero-spam/v1';
31+
32+
/**
33+
* Registers hooks for both REST and admin-ajax endpoints.
34+
*
35+
* @since TBD
36+
*/
37+
public function __construct() {
38+
add_action( 'rest_api_init', [ $this, 'register_rest_route' ] );
39+
add_action( 'wp_ajax_gf_zero_spam_token', [ $this, 'handle_ajax' ] );
40+
add_action( 'wp_ajax_nopriv_gf_zero_spam_token', [ $this, 'handle_ajax' ] );
41+
}
42+
43+
/**
44+
* Registers the REST API route for token minting.
45+
*
46+
* @since TBD
47+
*
48+
* @return void
49+
*/
50+
public function register_rest_route() {
51+
register_rest_route(
52+
self::REST_NAMESPACE,
53+
'/token',
54+
[
55+
'methods' => 'GET',
56+
'callback' => [ $this, 'handle_rest' ],
57+
'permission_callback' => '__return_true',
58+
'args' => [
59+
'form_id' => [
60+
'required' => true,
61+
'type' => 'integer',
62+
'sanitize_callback' => 'absint',
63+
],
64+
],
65+
]
66+
);
67+
}
68+
69+
/**
70+
* Handles the REST API token request.
71+
*
72+
* @since TBD
73+
*
74+
* @param WP_REST_Request $request The REST request.
75+
*
76+
* @return WP_REST_Response|WP_Error
77+
*/
78+
public function handle_rest( $request ) {
79+
$form_id = (int) $request->get_param( 'form_id' );
80+
81+
return $this->handle_token_request( $form_id );
82+
}
83+
84+
/**
85+
* Handles the admin-ajax token request.
86+
*
87+
* @since TBD
88+
*
89+
* @return void
90+
*/
91+
public function handle_ajax() {
92+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint; no nonce needed.
93+
$form_id = isset( $_REQUEST['form_id'] ) ? absint( $_REQUEST['form_id'] ) : 0;
94+
$result = $this->handle_token_request( $form_id );
95+
96+
nocache_headers();
97+
98+
if ( is_wp_error( $result ) ) {
99+
$status = (int) $result->get_error_data();
100+
101+
wp_send_json_error( $result->get_error_message(), $status );
102+
}
103+
104+
wp_send_json( $result->get_data() );
105+
}
106+
107+
/**
108+
* Shared handler that validates the request and mints a token.
109+
*
110+
* @since TBD
111+
*
112+
* @param int $form_id The form ID to mint a token for.
113+
*
114+
* @return WP_REST_Response|WP_Error
115+
*/
116+
private function handle_token_request( int $form_id ) {
117+
if ( $form_id < 1 ) {
118+
return new WP_Error( 'missing_form_id', __( 'A valid form_id is required.', 'gravity-forms-zero-spam' ), 400 );
119+
}
120+
121+
$form = GFAPI::get_form( $form_id );
122+
123+
if ( ! $form ) {
124+
return new WP_Error( 'invalid_form', __( 'Form not found.', 'gravity-forms-zero-spam' ), 400 );
125+
}
126+
127+
// Check if Zero Spam is enabled for this form.
128+
$enabled = gf_apply_filters( 'gf_zero_spam_check_key_field', $form_id, true, $form, [] );
129+
130+
if ( false === $enabled ) {
131+
return new WP_Error( 'zero_spam_disabled', __( 'Zero Spam is not enabled for this form.', 'gravity-forms-zero-spam' ), 400 );
132+
}
133+
134+
$rate_check = $this->check_rate_limit();
135+
136+
if ( is_wp_error( $rate_check ) ) {
137+
return $rate_check;
138+
}
139+
140+
$ttl = 600;
141+
$token = GF_Zero_Spam_Token::mint( $form_id, $ttl );
142+
$expires = time() + $ttl;
143+
144+
$response = new WP_REST_Response(
145+
[
146+
'token' => $token,
147+
'expires' => $expires,
148+
]
149+
);
150+
151+
$response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate' );
152+
153+
return $response;
154+
}
155+
156+
/**
157+
* Checks per-IP rate limit using transients.
158+
*
159+
* @since TBD
160+
*
161+
* @return true|WP_Error True if within limits, WP_Error if exceeded.
162+
*/
163+
private function check_rate_limit() {
164+
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- IP used only for hashing.
165+
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown';
166+
167+
/**
168+
* Filters the client IP address used for rate limiting.
169+
*
170+
* Useful for sites behind Cloudflare, load balancers, or reverse proxies
171+
* where REMOTE_ADDR is the proxy IP, not the visitor's IP.
172+
*
173+
* @since TBD
174+
*
175+
* @param string $ip The client IP address. Default: $_SERVER['REMOTE_ADDR'].
176+
*/
177+
$ip = apply_filters( 'gf_zero_spam_client_ip', $ip );
178+
179+
$ip_hash = md5( $ip );
180+
$key = 'gf_zs_rate_' . $ip_hash;
181+
182+
$count = (int) get_transient( $key );
183+
184+
/**
185+
* Filters the maximum number of token requests allowed per IP per minute.
186+
*
187+
* Increase for sites behind corporate NAT or shared IP environments.
188+
*
189+
* @since TBD
190+
*
191+
* @param int $limit The maximum request count per minute. Default: 30.
192+
*/
193+
$limit = (int) apply_filters( 'gf_zero_spam_rate_limit', self::RATE_LIMIT );
194+
195+
if ( $count >= $limit ) {
196+
return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'gravity-forms-zero-spam' ), 429 );
197+
}
198+
199+
set_transient( $key, $count + 1, 60 );
200+
201+
return true;
202+
}
203+
}

0 commit comments

Comments
 (0)