Skip to content

Commit ed771fe

Browse files
albigopencode
andauthored
Task/contact form 2 (#1429)
* Harden the contact form, captcha optional * Update language labels * Add eslint-plugin-jsdoc --------- Co-authored-by: opencode <opencode@opencode.ai>
1 parent 360e9e9 commit ed771fe

11 files changed

Lines changed: 1060 additions & 4653 deletions

‎assets/js/frontend.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ jQuery( '#sunflower-contact-form' ).on( 'submit', function ( e ) {
165165
captcha_token: jQuery( 'input[name="captcha_token"]' ).val(),
166166
sendCopy: jQuery( '#send-copy' ).val(),
167167
postId: jQuery( '#post-id' ).val(),
168+
website: jQuery( 'input[name="website"]' ).val(),
169+
form_ts: jQuery( 'input[name="form_ts"]' ).val(),
170+
form_ts_sig: jQuery( 'input[name="form_ts_sig"]' ).val(),
171+
captcha_on: jQuery( 'input[name="captcha_on"]' ).val(),
168172
},
169173
} )
170174
.done( function ( response ) {

‎functions/contact-form.php‎

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@
55
* @package sunflower
66
*/
77

8+
/**
9+
* Best-effort client IP for rate limiting.
10+
*
11+
* Behind a reverse proxy / DDEV router, REMOTE_ADDR is the proxy address and is
12+
* therefore shared by all visitors. A trusted proxy sets X-Forwarded-For with the
13+
* real client IP as the left-most entry, so we prefer that. IMPORTANT:
14+
* X-Forwarded-For is client-supplied and spoofable UNLESS a trusted proxy
15+
* overwrites/appends it - only rely on it when such a proxy is actually in front
16+
* (otherwise keep REMOTE_ADDR). Behind Cloudflare prefer CF-Connecting-IP.
17+
*
18+
* @return string Client IP, or '' if none could be determined.
19+
*/
20+
function sunflower_contact_form_client_ip() {
21+
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
22+
$sunflower_xff = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
23+
$sunflower_ip = trim( $sunflower_xff[0] );
24+
if ( filter_var( $sunflower_ip, FILTER_VALIDATE_IP ) ) {
25+
return $sunflower_ip;
26+
}
27+
}
28+
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
29+
}
30+
831
/**
932
* Render the Sunflower contact form.
1033
*/
@@ -15,33 +38,122 @@ function sunflower_contact_form() {
1538
return;
1639
}
1740

18-
$captcha_user_input = (int) sanitize_text_field( $_POST['captcha'] );
19-
$captcha_token = sanitize_text_field( $_POST['captcha_token'] );
20-
$captcha_salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : 'sunflower_default_fallback_salt';
21-
$expected_token = '';
41+
$sunflower_spam_salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : 'sunflower_default_fallback_salt';
2242

23-
// We need to find the sum that produces this token.
24-
// Since we only use numbers 1-9, there are very few possibilities (2 to 18).
25-
for ( $i = 2; $i <= 18; $i++ ) {
26-
if ( hash( 'sha256', $i . $captcha_salt ) === $captcha_token ) {
27-
$expected_sum = $i;
28-
break;
29-
}
43+
// Honeypot: a hidden field humans never see. If filled, silently accept and drop
44+
// (fake success so bots get no signal to adapt).
45+
if ( ! empty( $_POST['website'] ) ) {
46+
echo wp_json_encode(
47+
array(
48+
'code' => 200,
49+
'text' => __( 'Thank you. The form has been sent.', 'sunflower-contact-form' ),
50+
)
51+
);
52+
die();
3053
}
3154

32-
if ( ! isset( $expected_sum ) || $captcha_user_input !== $expected_sum ) {
55+
// Time-trap: reject forms submitted implausibly fast or long stale. The render
56+
// timestamp is HMAC-signed, so it cannot be forged or replayed with a new value.
57+
$sunflower_form_ts = isset( $_POST['form_ts'] ) ? (int) $_POST['form_ts'] : 0;
58+
$sunflower_form_ts_sig = isset( $_POST['form_ts_sig'] ) ? sanitize_text_field( wp_unslash( $_POST['form_ts_sig'] ) ) : '';
59+
$sunflower_captcha_on = ( isset( $_POST['captcha_on'] ) && '0' === (string) $_POST['captcha_on'] ) ? '0' : '1';
60+
$sunflower_expected_ts = hash_hmac( 'sha256', $sunflower_form_ts . '|' . $sunflower_captcha_on, $sunflower_spam_salt );
61+
62+
if ( ! $sunflower_form_ts || ! hash_equals( $sunflower_expected_ts, $sunflower_form_ts_sig ) ) {
3363
echo wp_json_encode(
3464
array(
3565
'code' => 500,
36-
'text' => __(
37-
'Form not sent. Captcha wrong. Please try again.',
38-
'sunflower-contact-form'
39-
),
66+
'text' => __( 'Form not sent. Please reload the page and try again.', 'sunflower-contact-form' ),
4067
)
4168
);
4269
die();
4370
}
4471

72+
$sunflower_elapsed = time() - $sunflower_form_ts;
73+
if ( $sunflower_elapsed < 3 || $sunflower_elapsed > HOUR_IN_SECONDS ) {
74+
echo wp_json_encode(
75+
array(
76+
'code' => 500,
77+
'text' => __( 'Form not sent. Please take a moment and try again.', 'sunflower-contact-form' ),
78+
)
79+
);
80+
die();
81+
}
82+
83+
// Rate limiting: cap submissions per client per short, fixed window to throttle
84+
// scripted floods. The window is anchored on the first hit so it truly resets
85+
// (the old code re-extended a 1h TTL on every hit and effectively never expired).
86+
$sunflower_ip = sunflower_contact_form_client_ip();
87+
if ( $sunflower_ip ) {
88+
$sunflower_rl_window = MINUTE_IN_SECONDS;
89+
$sunflower_rl_max = 5;
90+
$sunflower_rl_key = 'sunflower_cf_rl_' . md5( $sunflower_ip );
91+
$sunflower_rl_bucket = get_transient( $sunflower_rl_key );
92+
93+
// Old int format or missing -> start a fresh bucket (also self-heals the
94+
// previous hour-long integer transient).
95+
if ( ! is_array( $sunflower_rl_bucket ) || ! isset( $sunflower_rl_bucket['count'], $sunflower_rl_bucket['start'] ) ) {
96+
$sunflower_rl_bucket = array(
97+
'count' => 0,
98+
'start' => time(),
99+
);
100+
}
101+
102+
// Window elapsed -> reset counter.
103+
if ( ( time() - (int) $sunflower_rl_bucket['start'] ) >= $sunflower_rl_window ) {
104+
$sunflower_rl_bucket = array(
105+
'count' => 0,
106+
'start' => time(),
107+
);
108+
}
109+
110+
if ( $sunflower_rl_bucket['count'] >= $sunflower_rl_max ) {
111+
echo wp_json_encode(
112+
array(
113+
'code' => 500,
114+
'text' => __( 'Too many messages from your address. Please try again later.', 'sunflower-contact-form' ),
115+
)
116+
);
117+
die();
118+
}
119+
120+
++$sunflower_rl_bucket['count'];
121+
// TTL = remaining window, so the transient self-expires with the window and
122+
// blocked requests (which return above) never extend it.
123+
$sunflower_rl_ttl = $sunflower_rl_window - ( time() - (int) $sunflower_rl_bucket['start'] );
124+
set_transient( $sunflower_rl_key, $sunflower_rl_bucket, max( 1, $sunflower_rl_ttl ) );
125+
}
126+
127+
// Captcha only when this form was rendered with it enabled (flag is signed).
128+
if ( '1' === $sunflower_captcha_on ) {
129+
$captcha_user_input = (int) sanitize_text_field( $_POST['captcha'] );
130+
$captcha_token = sanitize_text_field( $_POST['captcha_token'] );
131+
$captcha_salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : 'sunflower_default_fallback_salt';
132+
$expected_token = '';
133+
134+
// We need to find the sum that produces this token.
135+
// Since we only use numbers 1-9, there are very few possibilities (2 to 18).
136+
for ( $i = 2; $i <= 18; $i++ ) {
137+
if ( hash( 'sha256', $i . $captcha_salt ) === $captcha_token ) {
138+
$expected_sum = $i;
139+
break;
140+
}
141+
}
142+
143+
if ( ! isset( $expected_sum ) || $captcha_user_input !== $expected_sum ) {
144+
echo wp_json_encode(
145+
array(
146+
'code' => 500,
147+
'text' => __(
148+
'Form not sent. Captcha wrong. Please try again.',
149+
'sunflower-contact-form'
150+
),
151+
)
152+
);
153+
die();
154+
}
155+
}
156+
45157
$name = sanitize_text_field( $_POST['name'] );
46158
if ( $name ) {
47159
$message[] = sprintf( __( 'Name', 'sunflower-contact-form' ) . ': %s', $name );
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"translation-revision-date":"2026-06-17 12:49+0200","generator":"WP-CLI\/2.12.0","source":"build\/contact-form\/index.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"de_DE","plural-forms":"nplurals=2; plural=(n != 1);"},"Contact Form":["Kontaktformular"],"The receiver address may be altered within the Sunflower theme settings. By default, emails are sent to the site administrator.":["Die Empf\u00e4nger*innen-Adresse kann in den Sunflower-Einstellungen ge\u00e4ndert werden. Standardm\u00e4\u00dfig wird das Formular an den*die Webseiten-Administrator*in gesendet."],"Title of the form":["Titel des Formulars"],"Mail To":["Empf\u00e4nger-E-Mail"],"Mail form to this address instead of default receiver.":["Sende Formular an die angegebene Adresse statt dem Standard-Empf\u00e4nger."],"default receiver":["Standard-Empf\u00e4nger"],"Require E-Mail":["E-Mail ist Pflichtfeld"],"Send confirmation to sender":["Best\u00e4tigung an den Absender schicken"],"This only sends a short confirmation that the form was submitted successfully. No user content will be included. It is recommended to use a ticket system for detailed confirmations.":["Dies versendet nur eine kurze Best\u00e4tigung \u00fcber den erfolgreichen Versand des Formulars. Es werden keine Benutzereingaben \u00fcbertragen. F\u00fcr detailliertere Best\u00e4tigungen wird die Nutzung eines Ticket-Systems empfohlen."],"Display Phone Field":["Telefon-Feld anzeigen"],"Require Phone":["Telefon ist Pflichtfeld"]}}}
1+
{"translation-revision-date":"2026-08-26 14:42+0200","generator":"WP-CLI\/2.12.0","source":"build\/contact-form\/index.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"de_DE","plural-forms":"nplurals=2; plural=(n != 1);"},"Contact Form":["Kontaktformular"],"The receiver address may be altered within the Sunflower theme settings. By default, emails are sent to the site administrator.":["Die Empf\u00e4nger*innen-Adresse kann in den Sunflower-Einstellungen ge\u00e4ndert werden. Standardm\u00e4\u00dfig wird das Formular an den*die Webseiten-Administrator*in gesendet."],"Title of the form":["Titel des Formulars"],"Mail To":["Empf\u00e4nger E-Mail"],"Mail form to this address instead of default receiver.":["Sende Formular an die angegebene Adresse statt dem Standard-Empf\u00e4nger."],"default receiver":["Standard-Empf\u00e4nger"],"Require E-Mail":["E-Mail ist Pflichtfeld"],"Send confirmation to sender":["Best\u00e4tigung an den Absender schicken"],"This only sends a short confirmation that the form was submitted successfully. No user content will be included. It is recommended to use a ticket system for detailed confirmations.":["Dies versendet nur eine kurze Best\u00e4tigung \u00fcber den erfolgreichen Versand des Formulars. Es werden keine Benutzereingaben \u00fcbertragen. F\u00fcr detailliertere Best\u00e4tigungen wird die Nutzung eines Ticket-Systems empfohlen."],"Display Phone Field":["Telefon-Feld anzeigen"],"Require Phone":["Telefon ist Pflichtfeld"],"Show number captcha":["Zeige Zahlen-Captcha"],"Simple arithmetic captcha as spam protection. Honeypot, time trap and rate limiting stay active regardless.":["Einfaches Rechen-Captcha als Spam-Schutz. Die anderen Spam-Schutz Mechanismen bleiben aktiv."]}}}
495 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)