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>
168 lines
6.0 KiB
PHP
168 lines
6.0 KiB
PHP
<?php
|
|
/**
|
|
* Transient-backed Rate Limiter + Trustworthy Client IP
|
|
*
|
|
* Generic check/record/reset limiter used by every public endpoint
|
|
* (login, register, contact, newsletter, verify-resend). State lives
|
|
* in WordPress transients — auto-expires, no cleanup required.
|
|
*
|
|
* Storage key format: ps_rl_{action}_{hash} where
|
|
* hash = substr( wp_hash( $ip . '|' . $identifier ), 0, 16 ).
|
|
* Raw IPs are never stored.
|
|
*
|
|
* @see inc/config.php — supplies per-action limit + window.
|
|
* @see inc/cloudflare-ips.php — edge-range allowlist for CF-Connecting-IP trust.
|
|
* @see inc/auth-handlers.php, inc/contact-handler.php — primary callers.
|
|
* @see functions.php — includes this file.
|
|
*
|
|
* What: Rate limit check/record/reset + peer-validated client-IP resolver.
|
|
* Who calls it: Every public handler after nonce verification.
|
|
* Dependencies: WordPress transients, inc/config.php, inc/cloudflare-ips.php.
|
|
*
|
|
* @package peptide-starter
|
|
*/
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Static rate limiter using WordPress transients.
|
|
*
|
|
* What: Tracks attempt counts per (action, identifier) tuple.
|
|
* Who calls it: Public handlers after nonce verification.
|
|
* Dependencies: Transient storage, peptide_starter_security_config().
|
|
*/
|
|
class Peptide_Starter_Rate_Limiter {
|
|
|
|
/**
|
|
* Build the transient key for a given action + identifier.
|
|
*
|
|
* Uses a truncated wp_hash so the stored key never exposes the raw IP
|
|
* or raw identifier. 16 hex chars ≈ 64 bits of collision resistance.
|
|
*
|
|
* @param string $action Action slug (e.g. 'login').
|
|
* @param string $identifier Extra identifier (email hash, form slug, etc.).
|
|
* @return string Transient key, always prefixed ps_rl_.
|
|
*/
|
|
protected static function build_key( $action, $identifier ) {
|
|
$ip = peptide_starter_get_client_ip();
|
|
$hash = substr( wp_hash( $ip . '|' . $identifier ), 0, 16 );
|
|
return 'ps_rl_' . preg_replace( '/[^a-z0-9_]/i', '', $action ) . '_' . $hash;
|
|
}
|
|
|
|
/**
|
|
* Whether the caller is still within their budget.
|
|
*
|
|
* Does not mutate state — call record() on failure (or at the start of
|
|
* every attempt if you want to meter successes too).
|
|
*
|
|
* @param string $action Action slug configured in security config.
|
|
* @param string $identifier Caller-supplied identifier (e.g. sha256(email)).
|
|
* @return bool True if allowed, false if over budget.
|
|
*/
|
|
public static function check( $action, $identifier ) {
|
|
$limits = peptide_starter_config_rate_limit( $action );
|
|
$key = self::build_key( $action, $identifier );
|
|
$current = (int) get_transient( $key );
|
|
return $current < $limits['limit'];
|
|
}
|
|
|
|
/**
|
|
* Record an attempt — increments the counter, sets TTL on first write.
|
|
*
|
|
* Side effects: writes a transient.
|
|
*
|
|
* @param string $action Action slug.
|
|
* @param string $identifier Caller-supplied identifier.
|
|
* @return void
|
|
*/
|
|
public static function record( $action, $identifier ) {
|
|
$limits = peptide_starter_config_rate_limit( $action );
|
|
$key = self::build_key( $action, $identifier );
|
|
$current = (int) get_transient( $key );
|
|
set_transient( $key, $current + 1, $limits['window'] );
|
|
}
|
|
|
|
/**
|
|
* Clear the counter for an action + identifier.
|
|
*
|
|
* Side effects: deletes a transient.
|
|
*
|
|
* @param string $action Action slug.
|
|
* @param string $identifier Caller-supplied identifier.
|
|
* @return void
|
|
*/
|
|
public static function reset( $action, $identifier ) {
|
|
$key = self::build_key( $action, $identifier );
|
|
delete_transient( $key );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the client IP we will trust for rate-limit keying.
|
|
*
|
|
* Trust model (v1.5.2):
|
|
* - REMOTE_ADDR is the ground truth — it is always returned as the
|
|
* fallback and cannot be forged by a client.
|
|
* - HTTP_CF_CONNECTING_IP is trusted ONLY when REMOTE_ADDR is a known
|
|
* Cloudflare edge (see inc/cloudflare-ips.php). If the origin is
|
|
* reached directly (bypassing CF), attackers could otherwise spoof
|
|
* any IP they liked and bypass every rate limiter.
|
|
* - HTTP_X_FORWARDED_FOR is ignored by default. Operators who run
|
|
* behind a different trusted proxy can opt-in via the
|
|
* peptide_starter_trust_xff filter returning true.
|
|
*
|
|
* Invalid / unparseable header values are silently ignored.
|
|
*
|
|
* @return string Validated IPv4/IPv6 string; '0.0.0.0' only if REMOTE_ADDR
|
|
* is empty or unparseable (shouldn't happen in practice).
|
|
*/
|
|
function peptide_starter_get_client_ip() {
|
|
$remote_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
|
|
$remote = filter_var( $remote_raw, FILTER_VALIDATE_IP ) ? $remote_raw : '0.0.0.0';
|
|
|
|
// CF-Connecting-IP — only trusted when the peer itself is a CF edge.
|
|
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] )
|
|
&& function_exists( 'peptide_starter_is_cloudflare_peer' )
|
|
&& peptide_starter_is_cloudflare_peer( $remote ) ) {
|
|
$cf = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
|
|
if ( filter_var( $cf, FILTER_VALIDATE_IP ) ) {
|
|
/**
|
|
* Filter the resolved client IP.
|
|
*
|
|
* @param string $ip Resolved IP.
|
|
*/
|
|
return apply_filters( 'peptide_starter_client_ip', $cf );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether to trust HTTP_X_FORWARDED_FOR. Off by default — only enable
|
|
* when a known, trusted non-CF proxy sits in front of the origin.
|
|
*
|
|
* @param bool $trust Default false.
|
|
*/
|
|
$trust_xff = (bool) apply_filters( 'peptide_starter_trust_xff', false );
|
|
if ( $trust_xff && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
|
|
$xff = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
|
|
$first = trim( explode( ',', $xff )[0] );
|
|
if ( filter_var( $first, FILTER_VALIDATE_IP ) ) {
|
|
return apply_filters( 'peptide_starter_client_ip', $first );
|
|
}
|
|
}
|
|
|
|
return apply_filters( 'peptide_starter_client_ip', $remote );
|
|
}
|
|
|
|
/**
|
|
* Hash an identifier for rate-limit keying without storing plaintext.
|
|
*
|
|
* @param string $value Raw identifier.
|
|
* @return string 32-char hex substring of a sha256.
|
|
*/
|
|
function peptide_starter_hash_identifier( $value ) {
|
|
return substr( hash( 'sha256', (string) $value ), 0, 32 );
|
|
}
|