1.7.1: security fixes for the checkout and the client IP
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m14s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 4m34s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m14s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 4m34s
- The discount follows the billing email, the address the order is placed under. The tracked session email used to win, so a guest could post a discounted address to the tracking endpoint and check out under their own, keeping the discount. - Shipping packages carry the matched assignment, so WooCommerce's per-package rate cache no longer reuses rates filtered for a matched address (free shipping, reserved methods) after the email changes. - The anonymous tracking endpoint no longer answers matched: true|false, which let anyone enumerate discounted addresses and domains. - CF-Connecting-IP is believed only from a Cloudflare edge range or a private/loopback proxy. From anyone else it let a direct client pick a new address per request: no REST rate limit, forged audit-log IPs. Smoke tests: 89 assertions (was 73), each fix with its own. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4a93a5fde8
commit
d5d85612bd
+105
-11
@@ -78,27 +78,64 @@ class WC_EDM_Audit_Log {
|
||||
return max( 1, (int) apply_filters( 'wc_edm_audit_log_retention_days', 30 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare's published edge ranges (https://www.cloudflare.com/ips/).
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
const CLOUDFLARE_RANGES = 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',
|
||||
'2400:cb00::/32',
|
||||
'2606:4700::/32',
|
||||
'2803:f800::/32',
|
||||
'2405:b500::/32',
|
||||
'2405:8100::/32',
|
||||
'2a06:98c0::/29',
|
||||
'2c0f:f248::/32',
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the client IP address.
|
||||
*
|
||||
* The shop may sit behind a reverse proxy (e.g. Cloudflare), where
|
||||
* REMOTE_ADDR is the proxy rather than the visitor. Proxy headers are
|
||||
* only trustworthy when the proxy is guaranteed to set them, so this is
|
||||
* filterable for setups that need different handling.
|
||||
* REMOTE_ADDR is the only value a visitor cannot choose. The shop may sit
|
||||
* behind Cloudflare, where REMOTE_ADDR is the proxy, so CF-Connecting-IP
|
||||
* is used — but only when the request really came from a proxy: one of
|
||||
* Cloudflare's edge ranges, or a private/loopback address (a Cloudflare
|
||||
* tunnel or a reverse proxy on the same host or network).
|
||||
*
|
||||
* 🔴 Up to 1.7.0 the header was believed from anyone. A client talking to
|
||||
* the shop directly could send a new CF-Connecting-IP with every request,
|
||||
* so each one landed in a fresh rate-limit bucket (no limit at all on the
|
||||
* REST API) and the audit log recorded whatever address it was told.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_client_ip() {
|
||||
$ip = '';
|
||||
$remote = ! empty( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
|
||||
$remote = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '';
|
||||
$ip = $remote;
|
||||
|
||||
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
|
||||
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
|
||||
if ( $remote && ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && self::is_trusted_proxy( $remote ) ) {
|
||||
$forwarded = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
|
||||
if ( filter_var( $forwarded, FILTER_VALIDATE_IP ) ) {
|
||||
$ip = $forwarded;
|
||||
}
|
||||
}
|
||||
|
||||
$ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
|
||||
|
||||
/**
|
||||
* Filter the resolved client IP used for logging and rate limiting.
|
||||
*
|
||||
@@ -107,6 +144,63 @@ class WC_EDM_Audit_Log {
|
||||
return (string) apply_filters( 'wc_edm_client_ip', $ip );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a connecting address is a proxy whose CF-Connecting-IP is believed.
|
||||
*
|
||||
* @param string $remote REMOTE_ADDR, already validated.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_trusted_proxy( $remote ) {
|
||||
// Private and loopback: a tunnel or reverse proxy on our own network.
|
||||
// FILTER_FLAG_NO_PRIV_RANGE rejects them, so failing it means private.
|
||||
if ( ! filter_var( $remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE ) || self::in_cidr( $remote, '127.0.0.0/8' ) || '::1' === $remote ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ( self::CLOUDFLARE_RANGES as $range ) {
|
||||
if ( self::in_cidr( $remote, $range ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an address lies inside a CIDR range (IPv4 or IPv6).
|
||||
*
|
||||
* @param string $ip Address.
|
||||
* @param string $range CIDR range.
|
||||
* @return bool
|
||||
*/
|
||||
public static function in_cidr( $ip, $range ) {
|
||||
list( $subnet, $bits ) = explode( '/', $range );
|
||||
|
||||
$ip_bin = inet_pton( $ip );
|
||||
$subnet_bin = inet_pton( $subnet );
|
||||
|
||||
if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bits = (int) $bits;
|
||||
$bytes = intdiv( $bits, 8 );
|
||||
|
||||
if ( substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rest = $bits % 8;
|
||||
|
||||
if ( 0 === $rest ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$mask = chr( ( 0xff << ( 8 - $rest ) ) & 0xff );
|
||||
|
||||
return ( $ip_bin[ $bytes ] & $mask ) === ( $subnet_bin[ $bytes ] & $mask );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an entry.
|
||||
*
|
||||
|
||||
+59
-20
@@ -38,6 +38,31 @@ class WC_EDM_Frontend {
|
||||
add_action( 'woocommerce_cart_calculate_fees', array( $this, 'apply_discount_fees' ), 20 );
|
||||
add_action( 'woocommerce_before_calculate_totals', array( $this, 'maybe_apply_coupon' ), 20 );
|
||||
add_filter( 'woocommerce_package_rates', array( $this, 'filter_shipping_rates' ), 20, 2 );
|
||||
add_filter( 'woocommerce_cart_shipping_packages', array( $this, 'key_packages_to_match' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the customer's matched assignment into every shipping package.
|
||||
*
|
||||
* WooCommerce caches the rates it computed for a package in the session,
|
||||
* keyed on a hash of the package — contents and destination, not who the
|
||||
* customer is. Rates filtered for a matched address (free shipping, a
|
||||
* reserved method) were therefore reused for the same cart after the
|
||||
* email changed, including at the moment the order is placed. Putting the
|
||||
* match in the package makes it part of that hash.
|
||||
*
|
||||
* @param array $packages Shipping packages.
|
||||
* @return array
|
||||
*/
|
||||
public function key_packages_to_match( $packages ) {
|
||||
$match = $this->get_current_match();
|
||||
$key = $match ? (int) $match->id : 0;
|
||||
|
||||
foreach ( $packages as $index => $package ) {
|
||||
$packages[ $index ]['wc_edm_match'] = $key;
|
||||
}
|
||||
|
||||
return $packages;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,21 +129,28 @@ class WC_EDM_Frontend {
|
||||
wp_send_json_error( array( 'message' => 'No session.' ) );
|
||||
}
|
||||
|
||||
if ( $email && is_email( $email ) ) {
|
||||
WC()->session->set( self::SESSION_EMAIL_KEY, $email );
|
||||
} else {
|
||||
WC()->session->set( self::SESSION_EMAIL_KEY, null );
|
||||
$email = ( $email && is_email( $email ) ) ? $email : '';
|
||||
|
||||
WC()->session->set( self::SESSION_EMAIL_KEY, $email ? $email : null );
|
||||
|
||||
// The address is written where WooCommerce keeps the billing email,
|
||||
// because that is what get_customer_email() reads first and what the
|
||||
// checkout overwrites with the address the order is actually placed
|
||||
// under. The discount therefore follows the order's own email, never
|
||||
// one that was merely typed earlier.
|
||||
if ( WC()->customer ) {
|
||||
WC()->customer->set_billing_email( $email );
|
||||
WC()->customer->save();
|
||||
}
|
||||
|
||||
$this->clear_shipping_cache();
|
||||
|
||||
$match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null;
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'matched' => (bool) $match,
|
||||
)
|
||||
);
|
||||
// 🔴 Deliberately says nothing about whether the address matched.
|
||||
// This endpoint answers anonymous visitors, and up to 1.7.0 it
|
||||
// returned `matched: true|false` — one request per guess told a
|
||||
// stranger which addresses and company domains the shop discounts.
|
||||
// The checkout refresh this triggers is all the script needs.
|
||||
wp_send_json_success();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,8 +171,15 @@ class WC_EDM_Frontend {
|
||||
/**
|
||||
* Resolve the email address of the current customer.
|
||||
*
|
||||
* Logged-in users use their account email; guests use the tracked
|
||||
* session email or the billing email WooCommerce already knows.
|
||||
* Logged-in users use their account email. Guests use the billing email
|
||||
* WooCommerce holds for them — at checkout that is the address the order
|
||||
* is placed under — and the tracked session email only when there is no
|
||||
* billing email at all.
|
||||
*
|
||||
* 🔴 The order used to be the other way round: the tracked session email
|
||||
* won. Anyone could post a discounted address to the tracking endpoint
|
||||
* and then place the order under their own, taking the discount with
|
||||
* nothing on the order pointing at the address that earned it.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
@@ -152,13 +191,6 @@ class WC_EDM_Frontend {
|
||||
}
|
||||
}
|
||||
|
||||
if ( WC()->session ) {
|
||||
$session_email = WC()->session->get( self::SESSION_EMAIL_KEY );
|
||||
if ( $session_email ) {
|
||||
return $session_email;
|
||||
}
|
||||
}
|
||||
|
||||
if ( WC()->customer ) {
|
||||
$billing_email = WC()->customer->get_billing_email();
|
||||
if ( $billing_email ) {
|
||||
@@ -166,6 +198,13 @@ class WC_EDM_Frontend {
|
||||
}
|
||||
}
|
||||
|
||||
if ( WC()->session ) {
|
||||
$session_email = WC()->session->get( self::SESSION_EMAIL_KEY );
|
||||
if ( $session_email ) {
|
||||
return (string) $session_email;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user