Files
peptide-starter-theme/inc/contact-handler.php
peptiderepo cb8b73f4ab v1.5.1: frontend auth hardening — rate limiting, email verification, honeypots, CSV safety
Security-only release per ADR-0001. Closes all six ship-blockers from the
post-v1.5.0 audit: PSEC-001 (no rate limiting), PSEC-002 (account
enumeration via separate error messages), PSEC-003 (registration auto-login
with no email verification), PSEC-004 (ungated "My Data" templates),
PSEC-005 (CSV formula injection on subscriber export), PSEC-006 (no
honeypot/CAPTCHA on public forms).

Adds inc/config.php (single source of truth for all security thresholds,
filterable via peptide_starter_security_config), inc/rate-limiter.php
(Peptide_Starter_Rate_Limiter + Cloudflare-aware client IP resolver,
transient-backed, never stores raw IPs), and inc/email-verification.php
(43-char token, 24h TTL, /verify rewrite, rate-limited resend endpoint).

Auth handlers rewritten to: unify all failure messages, rate-limit before
any expensive check, drop registrations with a filled honeypot silently,
stop auto-login on register, send a verification email instead. The
require_login() helper lives in inc/helpers.php and is applied at the top
of page-tracker.php, page-subject-log.php, and page-protocol-builder.php;
page-calculator.php stays open (no PII, no writes).

Newsletter signup gains: consent checkbox, privacy link, honeypot,
autoload=false on the wp_options blob, unsubscribe token stored per row,
/newsletter-unsubscribe route, merged success+duplicate into a single "ok"
state. Admin export runs every cell through peptide_starter_csv_safe()
which prefixes leading =/+/-/@/tab/CR with a quote.

Contact handler hardens against email-header injection (rejects name
values with \r \n , < >), rate-limits, honors honeypot, tags mail
failures with a request ID for correlation without logging message
content.

Adds Tools → Mail Test (inc/mail-diagnostic.php): a permanent wp_mail
probe so we can check Hostinger outbound deliverability any time it looks
flaky. Adds a one-time admin_init migration that enrols pre-v1.5.1
subscribers into the verification flow (currently a no-op — zero users —
but guarded for the future).

Other cleanup from the audit: fallback primary menu moved out of
header.php into inc/helpers.php (redeclaration risk), placeholder footer
language links replaced with "Translations coming soon", footer copyright
now reads through the customizer getter, settings-panel gains a proper
Tab/Shift+Tab focus trap and saves/restores body overflow, documentation
heading IDs slugified from heading text with collision counters, JS files
standardized on const/let.

Six new PHPUnit suites cover rate limiter lifecycle, auth handler error
unification and rate limits, email verification token generation and
expiry, contact handler header injection + rate limit + honeypot,
newsletter signup consent + CSV safety, and require_login gate behavior.

ARCHITECTURE.md and CONVENTIONS.md updated in the same commit — new
module layout, data flows, security patterns for rate-limiting,
honeypotting, gating, and CSV-safe export.

Version bumped to 1.5.1 in style.css and functions.php.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 13:01:22 +07:00

133 lines
5.5 KiB
PHP

<?php
/**
* Contact Form AJAX Handler
*
* Support contact form from the settings panel. Sanitises inputs,
* rejects header-injection characters in the sender name, enforces
* rate limits, honors the honeypot, and emails the admin.
*
* Unverified users may still use contact (it isn't a write endpoint
* on user data) — the verify gate is only applied to user-data tools.
*
* @see template-parts/settings-panel.php — renders the form
* @see inc/rate-limiter.php — Peptide_Starter_Rate_Limiter
* @see inc/config.php — contact_topics allowlist + rate limit
* @see functions.php — includes this file
*
* What: AJAX contact endpoint with abuse controls.
* Who calls it: admin-ajax.php via settings-panel.js POST.
* Dependencies: wp_mail, rate limiter, honeypot helper.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handle AJAX contact form submission.
*
* Side effects: may call wp_mail. Writes rate-limit transient.
*
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_contact() {
if ( ! isset( $_POST['ps_contact_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_contact_nonce'] ) ), 'ps_contact_form' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
if ( peptide_starter_honeypot_triggered( 'ps_hp_contact' ) ) {
// Fake success to poison the bot feedback loop.
wp_send_json_success( array( 'message' => __( 'Message sent successfully. We will respond within 48 hours.', 'peptide-starter' ) ) );
}
if ( ! Peptide_Starter_Rate_Limiter::check( 'contact', 'ip' ) ) {
wp_send_json_error( array( 'message' => __( 'Too many requests. Please try again later.', 'peptide-starter' ) ) );
}
Peptide_Starter_Rate_Limiter::record( 'contact', 'ip' );
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
// Reject header-injection characters in the name explicitly — don't
// rely on sanitize_text_field side-effects for email-header safety.
if ( preg_match( '/[\r\n,<>]/', $name ) ) {
peptide_starter_contact_log_failure( 'name_injection' );
wp_send_json_error( array( 'message' => __( 'Invalid characters in name.', 'peptide-starter' ) ) );
}
if ( '' === $name || '' === $email || '' === $topic || '' === $message ) {
peptide_starter_contact_log_failure( 'missing_field' );
wp_send_json_error( array( 'message' => __( 'All fields are required.', 'peptide-starter' ) ) );
}
if ( ! is_email( $email ) ) {
peptide_starter_contact_log_failure( 'bad_email' );
wp_send_json_error( array( 'message' => __( 'Please enter a valid email address.', 'peptide-starter' ) ) );
}
$allowed_topics = (array) peptide_starter_config_get( 'contact_topics', array( 'bug', 'feature', 'data', 'other' ) );
if ( ! in_array( $topic, $allowed_topics, true ) ) {
peptide_starter_contact_log_failure( 'bad_topic' );
wp_send_json_error( array( 'message' => __( 'Invalid topic selected.', 'peptide-starter' ) ) );
}
$topic_labels = array(
'bug' => __( 'Bug Report', 'peptide-starter' ),
'feature' => __( 'Feature Request', 'peptide-starter' ),
'data' => __( 'Data Correction', 'peptide-starter' ),
'other' => __( 'Other', 'peptide-starter' ),
);
$request_id = substr( wp_hash( microtime() . wp_rand() ), 0, 10 );
$subject = sprintf(
/* translators: 1: topic label, 2: sender name, 3: request id */
__( '[Peptide Repo] %1$s from %2$s (req %3$s)', 'peptide-starter' ),
isset( $topic_labels[ $topic ] ) ? $topic_labels[ $topic ] : $topic,
$name,
$request_id
);
$body = sprintf( __( 'Name: %s', 'peptide-starter' ), $name ) . "\n";
$body .= sprintf( __( 'Email: %s', 'peptide-starter' ), $email ) . "\n";
$body .= sprintf( __( 'Topic: %s', 'peptide-starter' ), isset( $topic_labels[ $topic ] ) ? $topic_labels[ $topic ] : $topic ) . "\n";
$body .= sprintf( __( 'Request ID: %s', 'peptide-starter' ), $request_id ) . "\n\n";
$body .= __( 'Message:', 'peptide-starter' ) . "\n";
$body .= $message;
$headers = array(
'Reply-To: ' . $email,
'Content-Type: text/plain; charset=UTF-8',
);
$sent = wp_mail( get_option( 'admin_email' ), $subject, $body, $headers );
if ( $sent ) {
wp_send_json_success( array( 'message' => __( 'Message sent successfully. We will respond within 48 hours.', 'peptide-starter' ) ) );
}
peptide_starter_contact_log_failure( 'mail_failed:' . $request_id );
wp_send_json_error( array( 'message' => __( 'Failed to send message. Please try again later.', 'peptide-starter' ) ) );
}
add_action( 'wp_ajax_ps_contact_submit', 'peptide_starter_ajax_contact' );
add_action( 'wp_ajax_nopriv_ps_contact_submit', 'peptide_starter_ajax_contact' );
/**
* Log a contact-handler failure with no PII — only a reason code and
* a hashed IP so admins can correlate without exposing submitters.
*
* @param string $reason Short reason code.
* @return void
*/
function peptide_starter_contact_log_failure( $reason ) {
$hash = substr( hash( 'sha256', peptide_starter_get_client_ip() ), 0, 12 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'peptide_starter_contact_failure reason=%s ip_hash=%s', $reason, $hash ) );
}