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:
@@ -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.
|
||||
|
||||
58
CHANGELOG.md
58
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
159
inc/cloudflare-ips.php
Normal 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 );
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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' );
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
154
style.css
154
style.css
@@ -4,7 +4,7 @@ Theme URI: https://github.com/peptiderepo/peptide-starter
|
||||
Description: Premium, accessible WordPress theme for scientific peptide reference database
|
||||
Author: Peptide Repo
|
||||
Author URI: https://peptiderepo.com
|
||||
Version: 1.5.1
|
||||
Version: 1.5.2
|
||||
License: GPL v2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
Text Domain: peptide-starter
|
||||
@@ -3077,79 +3077,79 @@ html[data-theme="dark"] .ps-modules-section {
|
||||
/* ============================================================================
|
||||
END OF STYLESHEET
|
||||
============================================================================ */
|
||||
|
||||
/* =====================================================================
|
||||
Section 26 — v1.5.1 security/UX additions
|
||||
===================================================================== */
|
||||
|
||||
/* Visually hidden honeypot wrapper — off-screen, keyboard-skipped, bot-visible. */
|
||||
.ps-hp-wrap {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Email verification banner on /profile */
|
||||
.ps-verify-required {
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-left: 4px solid #F59E0B;
|
||||
background-color: #FEF3C7;
|
||||
color: #78350F;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ps-verify-required {
|
||||
background-color: #451A03;
|
||||
color: #FEF3C7;
|
||||
border-left-color: #F59E0B;
|
||||
}
|
||||
|
||||
.ps-verify-required h3 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ps-verify-resend {
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.ps-verify-resend-status {
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Newsletter consent + privacy link */
|
||||
.ps-newsletter-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ps-newsletter-consent input[type="checkbox"] {
|
||||
margin-top: 0.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-newsletter-privacy {
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Footer translations note */
|
||||
.footer-languages-note {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Section 26 — v1.5.1 security/UX additions
|
||||
===================================================================== */
|
||||
|
||||
/* Visually hidden honeypot wrapper — off-screen, keyboard-skipped, bot-visible. */
|
||||
.ps-hp-wrap {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Email verification banner on /profile */
|
||||
.ps-verify-required {
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-left: 4px solid #F59E0B;
|
||||
background-color: #FEF3C7;
|
||||
color: #78350F;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ps-verify-required {
|
||||
background-color: #451A03;
|
||||
color: #FEF3C7;
|
||||
border-left-color: #F59E0B;
|
||||
}
|
||||
|
||||
.ps-verify-required h3 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ps-verify-resend {
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.ps-verify-resend-status {
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Newsletter consent + privacy link */
|
||||
.ps-newsletter-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ps-newsletter-consent input[type="checkbox"] {
|
||||
margin-top: 0.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-newsletter-privacy {
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Footer translations note */
|
||||
.footer-languages-note {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,51 @@ class Test_Auth_Handlers extends WP_UnitTestCase {
|
||||
$this->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 );
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user