v1.5.2: CF-peer validated client IP, grandfather migration, de-short-circuit register

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>
This commit is contained in:
peptiderepo
2026-04-14 15:39:09 +07:00
parent 3e1c12c94b
commit fff3fce432
13 changed files with 573 additions and 192 deletions

View File

@@ -160,11 +160,18 @@ function peptide_starter_ajax_register() {
$pattern = sprintf( '/^[a-zA-Z0-9]{%d,%d}$/', (int) $config['username_min'], (int) $config['username_max'] );
if ( ! preg_match( $pattern, $username ) ||
! is_email( $email ) ||
strlen( $password ) < (int) $config['password_min'] ||
username_exists( $username ) ||
email_exists( $email ) ) {
// 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() ) );
}

159
inc/cloudflare-ips.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
/**
* Cloudflare Edge IP Allowlist
*
* Static snapshot of Cloudflare's published IPv4 and IPv6 ranges used by
* the client-IP resolver to decide whether HTTP_CF_CONNECTING_IP can be
* trusted. Embedded — not fetched — so a compromised external source
* cannot grant attackers the right to spoof this header.
*
* Snapshot date: 2026-04-14
* Sources:
* - https://www.cloudflare.com/ips-v4
* - https://www.cloudflare.com/ips-v6
*
* Refresh cadence: review quarterly. Cloudflare historically adds ranges
* a few times a year and rarely removes them; a stale-but-superset list
* still protects us because an attacker cannot inject themselves into
* the list, and legitimate CF traffic from newer ranges simply falls
* back to REMOTE_ADDR (no loss of functionality, only loss of header
* trust on new-range origins — acceptable until next refresh).
*
* @see inc/rate-limiter.php — peptide_starter_get_client_ip() consumes
* @see functions.php — includes this file
*
* What: Provides peptide_starter_cloudflare_ip_ranges() (filterable) and
* peptide_starter_is_cloudflare_peer( $ip ) CIDR matcher.
* Who calls it: Only the IP resolver, on every public request.
* Dependencies: inet_pton, PHP bitwise.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Return the Cloudflare edge IPv4/IPv6 CIDR list.
*
* Override with add_filter( 'peptide_starter_cloudflare_ip_ranges', ... )
* if the list needs an out-of-band bump between releases.
*
* @return array { 'v4' => string[], 'v6' => string[] } of CIDR blocks.
*/
function peptide_starter_cloudflare_ip_ranges() {
$ranges = array(
'v4' => array(
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
),
'v6' => array(
'2400:cb00::/32',
'2606:4700::/32',
'2803:f800::/32',
'2405:b500::/32',
'2405:8100::/32',
'2a06:98c0::/29',
'2c0f:f248::/32',
),
);
/**
* Filter the Cloudflare edge IP ranges used by the client-IP resolver.
*
* Operators can override this without a code deploy. Shape must be an
* array with 'v4' and 'v6' keys, each a list of CIDR strings.
*
* @param array $ranges Default snapshot.
*/
return apply_filters( 'peptide_starter_cloudflare_ip_ranges', $ranges );
}
/**
* Check whether an IP address is within a Cloudflare edge range.
*
* Handles both IPv4 and IPv6. Returns false on unparseable input.
*
* @param string $ip Candidate IP (e.g. REMOTE_ADDR).
* @return bool True if $ip falls inside any CF CIDR in the current snapshot.
*/
function peptide_starter_is_cloudflare_peer( $ip ) {
if ( ! is_string( $ip ) || '' === $ip ) {
return false;
}
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return false;
}
$ranges = peptide_starter_cloudflare_ip_ranges();
$is_v6 = ( false !== strpos( $ip, ':' ) );
$list = $is_v6 ? ( isset( $ranges['v6'] ) ? $ranges['v6'] : array() ) : ( isset( $ranges['v4'] ) ? $ranges['v4'] : array() );
foreach ( $list as $cidr ) {
if ( peptide_starter_cidr_match( $ip, $cidr ) ) {
return true;
}
}
return false;
}
/**
* Test whether $ip is inside $cidr. Supports v4 and v6.
*
* Implementation note: we use inet_pton for both families so the same
* byte-compare loop handles v4 (4 bytes) and v6 (16 bytes). Mask bits
* beyond the prefix length are zeroed by ignoring them in the compare.
*
* @param string $ip IP to test.
* @param string $cidr Block in CIDR notation.
* @return bool
*/
function peptide_starter_cidr_match( $ip, $cidr ) {
if ( ! is_string( $cidr ) || false === strpos( $cidr, '/' ) ) {
return false;
}
list( $subnet, $bits ) = explode( '/', $cidr, 2 );
$bits = (int) $bits;
$ip_bin = @inet_pton( $ip );
$subnet_bin = @inet_pton( $subnet );
if ( false === $ip_bin || false === $subnet_bin ) {
return false;
}
if ( strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
return false; // Family mismatch.
}
if ( $bits < 0 || $bits > ( strlen( $ip_bin ) * 8 ) ) {
return false;
}
$full_bytes = intdiv( $bits, 8 );
$remaining = $bits % 8;
if ( $full_bytes > 0 && 0 !== substr_compare( $ip_bin, $subnet_bin, 0, $full_bytes ) ) {
return false;
}
if ( 0 === $remaining ) {
return true;
}
$mask = chr( ( 0xFF << ( 8 - $remaining ) ) & 0xFF );
$ip_byte = substr( $ip_bin, $full_bytes, 1 );
$subnet_byte = substr( $subnet_bin, $full_bytes, 1 );
return ( $ip_byte & $mask ) === ( $subnet_byte & $mask );
}

View File

@@ -99,10 +99,11 @@ function peptide_starter_send_verification_email( $user_id ) {
/**
* Whether a user has completed email verification.
*
* Users without the ps_pending_verification meta are treated as verified
* (covers accounts created before v1.5.1 that were never enrolled; the
* migration in inc/page-setup.php enrolls existing v1.5.0 users explicitly
* where applicable).
* Users without the ps_pending_verification meta are treated as verified.
* This includes all accounts created before v1.5.2 — the v1.5.1 re-verify
* migration was removed in v1.5.2 (see CHANGELOG § [1.5.2] PSEC-008);
* existing accounts are grandfathered. Verification is enforced on new
* registrations from v1.5.2 forward.
*
* @param int $user_id User ID.
* @return bool True if verified or exempt; false if pending.

View File

@@ -1,18 +1,21 @@
<?php
/**
* Auto-Create Pages + v1.5.0 User Migration
* Auto-Create Pages on Theme Activation
*
* On theme activation, creates the required WordPress pages with the
* correct templates assigned, and runs a one-time migration that enrols
* pre-v1.5.1 users into the email-verification flow.
* Creates the required WordPress pages with the correct templates
* assigned on theme activation, and performs small one-off cleanups
* tied to version bumps.
*
* As of v1.5.2 this file does NOT run a user-verification migration:
* existing accounts created before v1.5.2 are grandfathered as
* verified (see ADR-0001 addendum / CHANGELOG [1.5.2] PSEC-008).
*
* @see functions.php — includes this file
* @see inc/email-verification.php — peptide_starter_send_verification_email()
* @see ARCHITECTURE.md — page/template map
*
* What: Theme-activation hooks for page provisioning and user enrolment.
* What: Theme-activation hook for page provisioning + version cleanup.
* Who calls it: WordPress after_switch_theme.
* Dependencies: wp_insert_post, update_post_meta, user meta.
* Dependencies: wp_insert_post, update_post_meta.
*
* @package peptide-starter
*/
@@ -101,54 +104,9 @@ function peptide_starter_create_pages() {
update_post_meta( $page_id, '_wp_page_template', $page_data['template'] );
}
}
// v1.5.2 cleanup: the v1.5.1 migration writes this flag. Drop it on
// activation so the removed migration doesn't leave dangling state.
delete_option( 'ps_verify_migration_version' );
}
add_action( 'after_switch_theme', 'peptide_starter_create_pages' );
/**
* One-time migration: enrol v1.5.0 subscribers into email verification.
*
* Runs once per site — guarded by the ps_verify_migration_version option.
* Iterates subscribers in batches of 50 via wp_mail(). Users created by
* admin (any role above subscriber) are skipped — we assume admin-created
* accounts are trusted.
*
* Side effects: writes user meta + sends email per enrolled user.
*
* Cost note: worst case N emails on first admin page load after deploy.
* If the site has many users, move this to WP-Cron before shipping a
* large-user release (not a concern for the current zero-user site).
*
* @return void
*/
function peptide_starter_migrate_existing_users_to_verification() {
if ( ! is_admin() ) {
return;
}
$done = get_option( 'ps_verify_migration_version' );
if ( PEPTIDE_STARTER_VERSION === $done ) {
return;
}
$subscribers = get_users(
array(
'role' => 'subscriber',
'fields' => array( 'ID' ),
'number' => 500,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $subscribers as $user ) {
$already_enrolled = get_user_meta( $user->ID, 'ps_pending_verification', true );
if ( '' !== (string) $already_enrolled ) {
continue;
}
if ( function_exists( 'peptide_starter_send_verification_email' ) ) {
peptide_starter_send_verification_email( $user->ID );
}
}
update_option( 'ps_verify_migration_version', PEPTIDE_STARTER_VERSION, false );
}
add_action( 'admin_init', 'peptide_starter_migrate_existing_users_to_verification' );

View File

@@ -1,6 +1,6 @@
<?php
/**
* Transient-backed Rate Limiter
* 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
@@ -11,12 +11,13 @@
* 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 for any action+identifier pair.
* 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, peptide_starter_get_client_ip().
* Dependencies: WordPress transients, inc/config.php, inc/cloudflare-ips.php.
*
* @package peptide-starter
*/
@@ -39,8 +40,7 @@ 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 —
* sufficient for the cardinality of a WP site.
* 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.).
@@ -53,10 +53,10 @@ class Peptide_Starter_Rate_Limiter {
}
/**
* Check whether the caller is still within their budget.
* Whether the caller is still within their budget.
*
* Does not mutate state — call record() after a failed attempt (or at the
* start of every attempt if you want to meter successes too).
* 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)).
@@ -88,9 +88,6 @@ class Peptide_Starter_Rate_Limiter {
/**
* Clear the counter for an action + identifier.
*
* Call on successful login so a legit user isn't locked out by their
* own earlier typos.
*
* Side effects: deletes a transient.
*
* @param string $action Action slug.
@@ -104,53 +101,64 @@ class Peptide_Starter_Rate_Limiter {
}
/**
* Best-effort client IP resolver.
* Resolve the client IP we will trust for rate-limit keying.
*
* Order of trust (first match wins):
* 1. HTTP_CF_CONNECTING_IP — set by Cloudflare edge (peptiderepo.com uses CF).
* 2. HTTP_X_FORWARDED_FOR — first entry; only trusted on hosts that set it.
* 3. REMOTE_ADDR — direct peer.
* 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.
*
* Note: the hosting environment (Hostinger + Cloudflare) reliably populates
* HTTP_CF_CONNECTING_IP. If deployment moves off CF, the filter below can be
* used to override.
* Invalid / unparseable header values are silently ignored.
*
* @return string IPv4/IPv6 string or '0.0.0.0' when nothing parseable.
* @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() {
$candidates = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' );
$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';
foreach ( $candidates as $key ) {
if ( empty( $_SERVER[ $key ] ) ) {
continue;
// 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 );
}
$raw = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
$first = trim( explode( ',', $raw )[0] );
if ( filter_var( $first, FILTER_VALIDATE_IP ) ) {
$ip = $first;
break;
}
}
if ( '' === $ip ) {
$ip = '0.0.0.0';
}
/**
* Filter the resolved client IP — for custom infra overrides.
* 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 string $ip Resolved IP.
* @param bool $trust Default false.
*/
return apply_filters( 'peptide_starter_client_ip', $ip );
$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.
*
* Use this for any identifier you don't want to hash in-line at every call
* site (typically the lowercased email address on login).
*
* @param string $value Raw identifier.
* @return string 32-char hex substring of a sha256.
*/