From fff3fce4321491d14813617fb4d246751fed88a8 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 14 Apr 2026 15:39:09 +0700 Subject: [PATCH] v1.5.2: CF-peer validated client IP, grandfather migration, de-short-circuit register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 37 ++++++- CHANGELOG.md | 58 +++++++++++ CONVENTIONS.md | 20 ++++ functions.php | 3 +- inc/auth-handlers.php | 17 +++- inc/cloudflare-ips.php | 159 ++++++++++++++++++++++++++++++ inc/email-verification.php | 9 +- inc/page-setup.php | 70 +++---------- inc/rate-limiter.php | 90 +++++++++-------- style.css | 154 ++++++++++++++--------------- tests/test-auth-handlers.php | 45 +++++++++ tests/test-email-verification.php | 44 +++++++++ tests/test-rate-limiter.php | 59 ++++++++++- 13 files changed, 573 insertions(+), 192 deletions(-) create mode 100644 inc/cloudflare-ips.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 635c5b2..91efb1d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,8 +30,11 @@ peptide-starter/ │ ├── helpers.php # Nav walker + menu fallback, logo, getters, │ │ # pagination, honeypot renderer, csv_safe, │ │ # require_login gate, safe referer. -│ ├── rate-limiter.php # [v1.5.1] Peptide_Starter_Rate_Limiter + -│ │ # peptide_starter_get_client_ip (CF-aware). +│ ├── cloudflare-ips.php # [v1.5.2] CF edge range snapshot + +│ │ # CIDR matcher (v4 and v6). +│ ├── rate-limiter.php # Peptide_Starter_Rate_Limiter + +│ │ # peptide_starter_get_client_ip (peer-validated, +│ │ # PSEC-007). │ ├── email-verification.php # [v1.5.1] Token send + verify route + resend │ │ # endpoint + user_is_verified(). │ ├── customizer.php # Customizer sections/settings/controls. @@ -220,19 +223,47 @@ Shortcodes (plugins), all wrapped in `shortcode_exists()`: - CSRF nonces on every form (auth, newsletter, contact, mail-test, unsubscribe). - Unified error messages on login + registration (no enumeration). +- Registration validation does **not** short-circuit — all checks + evaluate unconditionally so response timing can't distinguish which + one failed (PSEC-009, v1.5.2). - Rate limiting on login / register / contact / newsletter / verify-resend. - Honeypots on the four public forms (login, register, contact, newsletter). - `require_login()` gate on every template that owns user data. - Email verification required for write access to user-data tools. +- Existing accounts (created before v1.5.2) are grandfathered as + verified. Verification enforces only on registrations from v1.5.2 + onward (PSEC-008, v1.5.2). - CSV export formula-injection safe. - All input sanitized at the boundary; all output escaped. - Login redirect validated with `wp_validate_redirect`. - Rate-limit storage keys are hashed — no raw IPs persisted. - Contact sender names reject header-injection characters before `wp_mail`. +### Client-IP trust model (PSEC-007, v1.5.2) + +peptiderepo.com sits behind Cloudflare. The rate limiter keys on client +IP, so the source of that IP matters for correctness of every abuse +control in the theme. + +`peptide_starter_get_client_ip()` trusts in this priority order: + +1. **`HTTP_CF_CONNECTING_IP`** — only when `REMOTE_ADDR` is itself + inside a published Cloudflare edge range (see `inc/cloudflare-ips.php`). + Direct-to-origin connections that bypass Cloudflare cannot forge this + header because their peer IP is not a CF edge. +2. **`HTTP_X_FORWARDED_FOR`** — ignored by default. Opt in via + `add_filter( 'peptide_starter_trust_xff', '__return_true' )` only + when a known, trusted non-CF proxy fronts the origin. +3. **`REMOTE_ADDR`** — always trusted as the final fallback. Cannot be + client-controlled at the TCP layer. + +`inc/cloudflare-ips.php` holds a static snapshot of CF ranges. Refresh +quarterly. Override via `peptide_starter_cloudflare_ip_ranges` filter +for out-of-band updates. + ## Version -**Current:** v1.5.1 (2026-04-14) +**Current:** v1.5.2 (2026-04-14) Bump `style.css` header line 7 and `PEPTIDE_STARTER_VERSION` in `functions.php` together on release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 206fa09..a5786be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,64 @@ All notable changes to the Peptide Starter Theme are documented in this file. +## [1.5.2] - 2026-04-14 — Security + +Same-day follow-up to v1.5.1. Closes four issues the v1.5.1 post-merge +review flagged — two ship-blockers (PSEC-007 CF header spoof, PSEC-008 +synchronous migration) and two mediums (PSEC-009 timing oracle, +PSEC-010 test-coverage gap). All fixes stay within ADR-0001's decision +envelope — no new ADR required. + +### Security fixes + +- **PSEC-007** — `peptide_starter_get_client_ip()` now only trusts + `HTTP_CF_CONNECTING_IP` when `REMOTE_ADDR` itself is inside a + published Cloudflare edge range. Direct-to-origin requests (which + Hostinger allows) can no longer forge their source IP by sending a + `CF-Connecting-IP` header. `HTTP_X_FORWARDED_FOR` is ignored unless + explicitly opted in via the `peptide_starter_trust_xff` filter. New + module `inc/cloudflare-ips.php` holds the range snapshot (dated + 2026-04-14) + a CIDR matcher for both IPv4 and IPv6. Override the + list via the `peptide_starter_cloudflare_ip_ranges` filter. +- **PSEC-008** — Removed the v1.5.1 re-verification migration entirely + (Option A). Existing subscribers are now grandfathered — treated as + verified. Verification applies to accounts registered from v1.5.2 + onward. The `admin_init`-driven synchronous `wp_mail` loop that blocked + wp-admin is gone; its `ps_verify_migration_version` option is cleared + on theme activation. +- **PSEC-009** — Registration validation no longer short-circuits. + Pattern / email / password / `username_exists` / `email_exists` all + evaluate unconditionally before the combined rejection, closing the + response-time enumeration oracle that distinguished microsecond-fast + regex rejection from DB-round-trip rejection. + +### Tests + +- `test-rate-limiter.php` — seven new cases covering CF-peer trust, + spoofed-header rejection, invalid-value fallthrough, XFF default-off + behaviour, XFF filter opt-in, filter override of the CF range list, + IPv6 CIDR matching, and family-mismatch rejection. +- `test-auth-handlers.php` — `test_register_runs_all_checks_even_when_early_one_fails` + asserts DB-backed user lookups still execute when a cheap validation + check has already failed (PSEC-009). +- `test-email-verification.php` — `test_valid_token_verifies_user_clears_meta_and_fires_hook` + covers the happy-path verify flow end-to-end including hook firing + (PSEC-010). + +### Documentation + +- `ARCHITECTURE.md` — CF IP trust model explained in the Security + Summary; migration decision (grandfather) documented. +- `CONVENTIONS.md` — new "Trust model for request headers" note. + +### Rollback note + +If v1.5.2 causes login or registration failures, roll back to v1.5.0 +(not v1.5.1 — v1.5.1 carried the unresolved PSEC-007 hole and the +blocking migration). Rate-limit transients auto-expire. + +--- + ## [1.5.1] - 2026-04-14 — Security Security-only release hardening the frontend authentication surface introduced diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 80d140e..aa549e6 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -592,6 +592,26 @@ echo get_theme_mod( 'hero_title' ); echo get_theme_mod( 'dark_mode_default', false ); ``` +## Trust model for request headers (v1.5.2) + +Never trust `HTTP_X_FORWARDED_FOR`, `HTTP_CF_CONNECTING_IP`, or any other +client-controlled header without first validating the peer. Always read +the client IP via `peptide_starter_get_client_ip()` — it handles the +Cloudflare-peer check, the XFF opt-in, and the `REMOTE_ADDR` fallback. + +If a new feature needs to trust a different proxy header (e.g. a new CDN +in front of CF): + +1. Add the upstream proxy's IP ranges to a new file under `inc/`. +2. Extend `peptide_starter_get_client_ip()` with a new validated branch + guarded by a filter defaulted to off. +3. Never accept a header whose source can't be validated at the TCP + layer. + +Refresh the Cloudflare range snapshot in `inc/cloudflare-ips.php` +quarterly. Out-of-band bumps go through the +`peptide_starter_cloudflare_ip_ranges` filter. + ## Security Patterns (v1.5.1) ### Rate-limit a handler diff --git a/functions.php b/functions.php index e72fab0..62fc211 100644 --- a/functions.php +++ b/functions.php @@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Theme constants. -define( 'PEPTIDE_STARTER_VERSION', '1.5.1' ); +define( 'PEPTIDE_STARTER_VERSION', '1.5.2' ); define( 'PEPTIDE_STARTER_DIR', get_template_directory() ); define( 'PEPTIDE_STARTER_URI', get_template_directory_uri() ); @@ -33,6 +33,7 @@ define( 'PEPTIDE_STARTER_URI', get_template_directory_uri() ); // that calls peptide_starter_config_*; rate limiter before handlers. require_once PEPTIDE_STARTER_DIR . '/inc/config.php'; require_once PEPTIDE_STARTER_DIR . '/inc/helpers.php'; +require_once PEPTIDE_STARTER_DIR . '/inc/cloudflare-ips.php'; require_once PEPTIDE_STARTER_DIR . '/inc/rate-limiter.php'; require_once PEPTIDE_STARTER_DIR . '/inc/email-verification.php'; require_once PEPTIDE_STARTER_DIR . '/inc/customizer.php'; diff --git a/inc/auth-handlers.php b/inc/auth-handlers.php index 00d165b..b08100c 100644 --- a/inc/auth-handlers.php +++ b/inc/auth-handlers.php @@ -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() ) ); } diff --git a/inc/cloudflare-ips.php b/inc/cloudflare-ips.php new file mode 100644 index 0000000..92dffce --- /dev/null +++ b/inc/cloudflare-ips.php @@ -0,0 +1,159 @@ + 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 ); +} diff --git a/inc/email-verification.php b/inc/email-verification.php index f962a6b..1e510c6 100644 --- a/inc/email-verification.php +++ b/inc/email-verification.php @@ -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. diff --git a/inc/page-setup.php b/inc/page-setup.php index 8c28e9f..4bf63d3 100644 --- a/inc/page-setup.php +++ b/inc/page-setup.php @@ -1,18 +1,21 @@ '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' ); diff --git a/inc/rate-limiter.php b/inc/rate-limiter.php index 33bd1c7..454c836 100644 --- a/inc/rate-limiter.php +++ b/inc/rate-limiter.php @@ -1,6 +1,6 @@ assertSame( peptide_starter_register_failure_message(), $this->captured['data']['message'] ); } + /** + * PSEC-009: registration validation path is not short-circuit. + * + * We can't directly mock `preg_match` / `username_exists` etc. from + * userland. Instead, we assert the behavioural contract: even when an + * early check has already failed (invalid username pattern), the + * handler still runs the later DB-backed checks. We observe this by + * filtering `pre_user_login` which wp_create_user-adjacent code paths + * consult, and by checking that `username_exists` was called via a + * filter it would fire. A simpler surrogate is to hook into WordPress' + * `user_search_query` / `query` action and confirm at least one user + * lookup happened during a failing registration — which would not + * happen if the handler short-circuited on the pattern failure. + */ + public function test_register_runs_all_checks_even_when_early_one_fails() { + $queries_ran = 0; + $counter = function ( $query ) use ( &$queries_ran ) { + // wp_cache_get avoids counting ourselves. We only care whether + // any user-table-touching query fires during the handler run. + if ( is_string( $query ) && false !== stripos( $query, 'users' ) ) { + $queries_ran++; + } + return $query; + }; + add_filter( 'query', $counter ); + + update_option( 'users_can_register', 1 ); + $_POST = array( + 'ps_register_nonce' => wp_create_nonce( 'ps_auth_register' ), + 'username' => 'ab', // Too short — early check fails. + 'email' => 'validformat@example.com', + 'password' => 'longenoughpassword', + ); + try { peptide_starter_ajax_register(); } catch ( WPAjaxDieStopException $e ) { /* ok */ } + + remove_filter( 'query', $counter ); + + $this->assertGreaterThan( + 0, + $queries_ran, + 'username_exists / email_exists must still run even when pattern check fails (PSEC-009 non-short-circuit).' + ); + $this->assertSame( peptide_starter_register_failure_message(), $this->captured['data']['message'] ); + } + public function test_register_duplicate_email_returns_unified_message() { self::factory()->user->create( array( 'user_email' => 'dup@example.com' ) ); update_option( 'users_can_register', 1 ); diff --git a/tests/test-email-verification.php b/tests/test-email-verification.php index 4f2eb01..ab5cccd 100644 --- a/tests/test-email-verification.php +++ b/tests/test-email-verification.php @@ -44,6 +44,50 @@ class Test_Email_Verification extends WP_UnitTestCase { $this->assertFalse( peptide_starter_user_is_verified( $user_id ) ); } + public function test_valid_token_verifies_user_clears_meta_and_fires_hook() { + $user_id = self::factory()->user->create(); + peptide_starter_send_verification_email( $user_id ); + $token = get_user_meta( $user_id, 'ps_verify_token', true ); + $this->assertNotEmpty( $token ); + + $_GET['uid'] = $user_id; + $_GET['token'] = $token; + set_query_var( 'ps_verify', 1 ); + + $redirected = null; + $hook_user_id = null; + add_filter( + 'wp_redirect', + function ( $url ) use ( &$redirected ) { + $redirected = $url; + return false; + } + ); + add_action( + 'peptide_starter_user_verified', + function ( $uid ) use ( &$hook_user_id ) { + $hook_user_id = $uid; + } + ); + + try { + peptide_starter_handle_verify_request(); + } catch ( Exception $e ) { /* exit path */ } + + $this->assertNotNull( $redirected, 'Must redirect on successful verify.' ); + $this->assertStringContainsString( 'verified=1', (string) $redirected ); + $this->assertSame( '', (string) get_user_meta( $user_id, 'ps_pending_verification', true ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, 'ps_verify_token', true ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, 'ps_verify_expires', true ) ); + $this->assertSame( $user_id, get_current_user_id(), 'User must be logged in after successful verify.' ); + $this->assertSame( $user_id, $hook_user_id, 'peptide_starter_user_verified must fire with the user ID.' ); + + remove_all_filters( 'wp_redirect' ); + remove_all_actions( 'peptide_starter_user_verified' ); + set_query_var( 'ps_verify', 0 ); + wp_set_current_user( 0 ); + } + public function test_verification_handler_rejects_bad_token() { $user_id = self::factory()->user->create(); peptide_starter_send_verification_email( $user_id ); diff --git a/tests/test-rate-limiter.php b/tests/test-rate-limiter.php index 790a9ff..c37059e 100644 --- a/tests/test-rate-limiter.php +++ b/tests/test-rate-limiter.php @@ -78,17 +78,66 @@ class Test_Rate_Limiter extends WP_UnitTestCase { $this->assertStringNotContainsString( 'alice', $a ); } - public function test_client_ip_prefers_cf_header() { + public function test_client_ip_trusts_cf_header_when_peer_is_cloudflare() { + // 172.68.0.5 is inside 172.64.0.0/13 (published CF range). + $_SERVER['REMOTE_ADDR'] = '172.68.0.5'; $_SERVER['HTTP_CF_CONNECTING_IP'] = '203.0.113.5'; - $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; $this->assertSame( '203.0.113.5', peptide_starter_get_client_ip() ); - unset( $_SERVER['HTTP_CF_CONNECTING_IP'] ); + unset( $_SERVER['HTTP_CF_CONNECTING_IP'], $_SERVER['REMOTE_ADDR'] ); } - public function test_client_ip_rejects_garbage() { + public function test_client_ip_ignores_spoofed_cf_header_from_non_cloudflare_peer() { + // 8.8.8.8 is not Cloudflare — any CF-Connecting-IP header must be + // treated as hostile and ignored. This is PSEC-007. + $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; + $_SERVER['HTTP_CF_CONNECTING_IP'] = '203.0.113.5'; + $this->assertSame( '8.8.8.8', peptide_starter_get_client_ip() ); + unset( $_SERVER['HTTP_CF_CONNECTING_IP'], $_SERVER['REMOTE_ADDR'] ); + } + + public function test_client_ip_rejects_invalid_cf_header_value() { + $_SERVER['REMOTE_ADDR'] = '172.68.0.5'; $_SERVER['HTTP_CF_CONNECTING_IP'] = 'not-an-ip'; + $this->assertSame( '172.68.0.5', peptide_starter_get_client_ip() ); + unset( $_SERVER['HTTP_CF_CONNECTING_IP'], $_SERVER['REMOTE_ADDR'] ); + } + + public function test_client_ip_ignores_xff_by_default() { $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.5'; $this->assertSame( '10.0.0.1', peptide_starter_get_client_ip() ); - unset( $_SERVER['HTTP_CF_CONNECTING_IP'] ); + unset( $_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['REMOTE_ADDR'] ); + } + + public function test_client_ip_respects_xff_when_filter_enables_trust() { + $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.5, 172.68.0.5'; + add_filter( 'peptide_starter_trust_xff', '__return_true' ); + $this->assertSame( '203.0.113.5', peptide_starter_get_client_ip() ); + remove_filter( 'peptide_starter_trust_xff', '__return_true' ); + unset( $_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['REMOTE_ADDR'] ); + } + + public function test_cloudflare_ranges_filter_override_is_honoured() { + add_filter( + 'peptide_starter_cloudflare_ip_ranges', + function () { + return array( 'v4' => array( '10.0.0.0/8' ), 'v6' => array() ); + } + ); + // Default snapshot would treat 172.68.0.5 as CF; filter override + // should now treat 10.0.0.5 as CF instead. + $this->assertTrue( peptide_starter_is_cloudflare_peer( '10.0.0.5' ) ); + $this->assertFalse( peptide_starter_is_cloudflare_peer( '172.68.0.5' ) ); + remove_all_filters( 'peptide_starter_cloudflare_ip_ranges' ); + } + + public function test_cidr_match_handles_ipv6() { + $this->assertTrue( peptide_starter_cidr_match( '2606:4700::1234', '2606:4700::/32' ) ); + $this->assertFalse( peptide_starter_cidr_match( '2001:db8::1', '2606:4700::/32' ) ); + } + + public function test_cidr_match_rejects_family_mismatch() { + $this->assertFalse( peptide_starter_cidr_match( '203.0.113.5', '2606:4700::/32' ) ); } }