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>
133 lines
5.5 KiB
PHP
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 ) );
|
|
}
|