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>
160 lines
4.6 KiB
PHP
160 lines
4.6 KiB
PHP
<?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 );
|
|
}
|