Security follow-up to v1.5.1. Closes four items from the v1.5.1 review — two ship-blockers and two mediums — all within the ADR-0001 envelope. PSEC-007 — peptide_starter_get_client_ip() now trusts CF-Connecting-IP only when REMOTE_ADDR falls inside a published Cloudflare edge range. Previously, any direct-to-origin request (which Hostinger permits) could forge its rate-limit key by setting the header to a different IP per attempt, bypassing every abuse control in the theme. New module inc/cloudflare-ips.php embeds a dated snapshot of CF v4/v6 ranges and a CIDR matcher covering both families; list is overridable via the peptide_starter_cloudflare_ip_ranges filter for out-of-band updates. HTTP_X_FORWARDED_FOR stays ignored unless operators opt in via the peptide_starter_trust_xff filter. PSEC-008 — Removed the v1.5.1 user-verification migration (Option A as confirmed by peptiderepo). The sync-wp_mail-per-subscriber loop on admin_init would have blocked wp-admin and hammered deliverability. Existing accounts are now grandfathered: no ps_pending_verification meta = verified. Verification applies only to registrations from v1.5.2 forward. The ps_verify_migration_version option (written by v1.5.1) is cleared on theme activation. PSEC-009 — Registration validation no longer short-circuits. All five checks (username pattern, email validity, password length, username_exists, email_exists) evaluate unconditionally before the combined rejection. Closes the response-time oracle that let an attacker distinguish microsecond-fast regex rejection from DB-round-trip rejection and infer which field triggered the failure. PSEC-010 — Added the happy-path verify test that v1.5.1 was missing. Covers token match, user meta cleanup, auth cookie set, and the peptide_starter_user_verified action firing with the correct user ID. Seven new rate-limiter tests confirm CF-peer validation works end-to-end: trusted CF peer honours the header, untrusted peer ignores spoofed headers, invalid values fall through, XFF default-off, XFF filter opt-in, CF range filter override, IPv6 CIDR match, family mismatch rejection. Confirmed with peptiderepo before coding: (1) Option A for PSEC-008 because zero users; (2) Cloudflare IS in front of the origin (dig confirms 104.21.x / 172.67.x + CF-Ray response header), so PSEC-007 is a real fix not dead code; (3) no staging — treat prod as staging. ARCHITECTURE.md gains a client-IP trust model section; CONVENTIONS.md documents the header-trust rules for future modules. Version bumped to 1.5.2 in style.css and functions.php. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
9.0 KiB
PHP
228 lines
9.0 KiB
PHP
<?php
|
|
/**
|
|
* Authentication AJAX Handlers
|
|
*
|
|
* Login + registration endpoints for the branded /auth page. Both are
|
|
* nonce-verified, rate-limited, honeypot-checked, and emit only unified
|
|
* error messages — no enumeration surface.
|
|
*
|
|
* Registration does NOT auto-login — users must click the verification
|
|
* link emailed to them (see inc/email-verification.php).
|
|
*
|
|
* @see page-auth.php — renders the forms
|
|
* @see assets/js/auth.js — AJAX submitter
|
|
* @see inc/rate-limiter.php — Peptide_Starter_Rate_Limiter
|
|
* @see inc/email-verification.php — sends verification email on register
|
|
* @see inc/config.php — thresholds for rate limits, username/password rules
|
|
* @see functions.php — includes this file
|
|
*
|
|
* What: AJAX login / register with unified errors and abuse controls.
|
|
* Who calls it: admin-ajax.php via auth.js POST.
|
|
* Dependencies: wp_signon, wp_create_user, rate limiter, email verification.
|
|
*
|
|
* @package peptide-starter
|
|
*/
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Unified login failure message.
|
|
*
|
|
* Kept in a helper so every failure path returns byte-identical output —
|
|
* critical for defeating account enumeration via error-message oracle.
|
|
*
|
|
* @return string Translated failure message.
|
|
*/
|
|
function peptide_starter_login_failure_message() {
|
|
return __( 'Invalid email or password.', 'peptide-starter' );
|
|
}
|
|
|
|
/**
|
|
* Unified registration failure message.
|
|
*
|
|
* @return string Translated failure message.
|
|
*/
|
|
function peptide_starter_register_failure_message() {
|
|
return __( 'Unable to create account. Please check your entries and try again.', 'peptide-starter' );
|
|
}
|
|
|
|
/**
|
|
* Handle AJAX login.
|
|
*
|
|
* Order of operations:
|
|
* 1. Nonce check (returns security error — separate from auth failure).
|
|
* 2. Honeypot check (silent fake success).
|
|
* 3. Rate-limit check keyed on IP + sha256(lowercased email).
|
|
* 4. Email lookup + wp_signon.
|
|
* 5. On success: reset the limiter; redirect via wp_validate_redirect.
|
|
*
|
|
* Side effects: may set auth cookie, writes rate-limit transient.
|
|
*
|
|
* @return void Emits JSON and exits.
|
|
*/
|
|
function peptide_starter_ajax_login() {
|
|
if ( ! isset( $_POST['ps_login_nonce'] ) ||
|
|
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_login_nonce'] ) ), 'ps_auth_login' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
// Honeypot — any value means bot. Return fake success, don't log PII.
|
|
if ( peptide_starter_honeypot_triggered( 'ps_hp_login' ) ) {
|
|
wp_send_json_success( array( 'redirect' => home_url( '/' ) ) );
|
|
}
|
|
|
|
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
|
|
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
|
|
|
|
$identifier = peptide_starter_hash_identifier( strtolower( $email ) );
|
|
|
|
if ( ! Peptide_Starter_Rate_Limiter::check( 'login', $identifier ) ) {
|
|
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
|
|
}
|
|
|
|
if ( '' === $email || '' === $password ) {
|
|
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
|
|
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
|
|
}
|
|
|
|
$user = get_user_by( 'email', $email );
|
|
if ( ! $user ) {
|
|
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
|
|
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
|
|
}
|
|
|
|
$creds = array(
|
|
'user_login' => $user->user_login,
|
|
'user_password' => $password,
|
|
'remember' => true,
|
|
);
|
|
|
|
$signed_in = wp_signon( $creds, is_ssl() );
|
|
|
|
if ( is_wp_error( $signed_in ) ) {
|
|
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
|
|
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
|
|
}
|
|
|
|
Peptide_Starter_Rate_Limiter::reset( 'login', $identifier );
|
|
|
|
$redirect_raw = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : home_url( '/' );
|
|
$redirect = wp_validate_redirect( $redirect_raw, home_url( '/' ) );
|
|
|
|
// Unverified users land on profile with the verify banner.
|
|
if ( ! peptide_starter_user_is_verified( $signed_in->ID ) ) {
|
|
$redirect = home_url( '/profile?verify_required=1' );
|
|
}
|
|
|
|
wp_send_json_success( array( 'redirect' => $redirect ) );
|
|
}
|
|
add_action( 'wp_ajax_nopriv_ps_auth_login', 'peptide_starter_ajax_login' );
|
|
|
|
/**
|
|
* Handle AJAX registration.
|
|
*
|
|
* Never auto-logs-in. Sends a verification email and responds with a
|
|
* "check your inbox" message. All validation-failure paths collapse to
|
|
* one message to avoid username/email enumeration.
|
|
*
|
|
* Side effects: creates a user, writes verify meta, sends email.
|
|
*
|
|
* @return void Emits JSON and exits.
|
|
*/
|
|
function peptide_starter_ajax_register() {
|
|
if ( ! isset( $_POST['ps_register_nonce'] ) ||
|
|
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_register_nonce'] ) ), 'ps_auth_register' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
if ( peptide_starter_honeypot_triggered( 'ps_hp_register' ) ) {
|
|
wp_send_json_success( array( 'message' => __( 'Check your email for a verification link.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
$config = peptide_starter_security_config();
|
|
|
|
if ( ! $config['registration_enabled'] || ! get_option( 'users_can_register' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Registration is currently disabled.', 'peptide-starter' ) ) );
|
|
}
|
|
|
|
// Rate-limit registration by IP only (identifier empty → IP-only bucket).
|
|
if ( ! Peptide_Starter_Rate_Limiter::check( 'register', 'ip' ) ) {
|
|
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
|
|
}
|
|
Peptide_Starter_Rate_Limiter::record( 'register', 'ip' );
|
|
|
|
$username = isset( $_POST['username'] ) ? sanitize_user( wp_unslash( $_POST['username'] ) ) : '';
|
|
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
|
|
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
|
|
|
|
$pattern = sprintf( '/^[a-zA-Z0-9]{%d,%d}$/', (int) $config['username_min'], (int) $config['username_max'] );
|
|
|
|
// Evaluate every check unconditionally so response time doesn't reveal
|
|
// which branch failed. `username_exists` and `email_exists` are each a
|
|
// DB round-trip; skipping them when an earlier cheap check already
|
|
// failed produces a response-time oracle that enables enumeration. We
|
|
// intentionally accept the extra queries as the cost of not leaking.
|
|
$invalid_username = ! preg_match( $pattern, $username );
|
|
$invalid_email = ! is_email( $email );
|
|
$invalid_password = strlen( $password ) < (int) $config['password_min'];
|
|
$username_taken = (bool) username_exists( $username );
|
|
$email_taken = (bool) email_exists( $email );
|
|
|
|
if ( $invalid_username || $invalid_email || $invalid_password || $username_taken || $email_taken ) {
|
|
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
|
|
}
|
|
|
|
$user_id = wp_create_user( $username, $password, $email );
|
|
|
|
if ( is_wp_error( $user_id ) ) {
|
|
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
|
|
}
|
|
|
|
// Send verification email. Intentionally ignore the wp_mail boolean —
|
|
// we never tell the client whether delivery succeeded (attacker signal).
|
|
peptide_starter_send_verification_email( $user_id );
|
|
|
|
/**
|
|
* Fires after a new user registers via the frontend auth form.
|
|
*
|
|
* Listeners may attach additional profile-setup logic. Fired BEFORE
|
|
* email verification is complete — do not grant privileged access from
|
|
* this hook without checking peptide_starter_user_is_verified().
|
|
*
|
|
* @param int $user_id The newly created user ID.
|
|
*/
|
|
do_action( 'peptide_starter_user_registered', $user_id );
|
|
|
|
wp_send_json_success(
|
|
array(
|
|
'message' => __( 'Account created. Check your inbox for a verification link — it expires in 24 hours.', 'peptide-starter' ),
|
|
)
|
|
);
|
|
}
|
|
add_action( 'wp_ajax_nopriv_ps_auth_register', 'peptide_starter_ajax_register' );
|
|
|
|
/**
|
|
* Detect a tripped honeypot field.
|
|
*
|
|
* Hidden fields should never receive input from a human using the real
|
|
* form. Any non-empty value → bot, silently drop the request.
|
|
*
|
|
* Side effects: on trip, writes a single low-signal log line with only
|
|
* a hash of the client IP — zero PII.
|
|
*
|
|
* @param string $field_name POST key for the honeypot input.
|
|
* @return bool True if the trap was triggered.
|
|
*/
|
|
function peptide_starter_honeypot_triggered( $field_name ) {
|
|
if ( empty( $_POST[ $field_name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller already verifies nonce.
|
|
return false;
|
|
}
|
|
$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_honeypot field=%s ip_hash=%s', $field_name, $hash ) );
|
|
return true;
|
|
}
|