1.7.1: security fixes for the checkout and the client IP #3

Merged
kw merged 1 commits from shipping-only into main 2026-11-14 14:32:42 +00:00
8 changed files with 263 additions and 38 deletions
Showing only changes of commit d5d85612bd - Show all commits
+7 -1
View File
@@ -4,7 +4,7 @@ Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 7.1.2
Requires PHP: 7.4
Stable tag: 1.7.0
Stable tag: 1.7.1
WC requires at least: 6.0
WC tested up to: 11.1.2
License: GPLv2 or later
@@ -97,6 +97,12 @@ The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.7.1 =
* Security: the discount now follows the email address the order is placed under. Before, a guest could enter a discounted address at checkout, change it to their own, and keep the discount.
* Security: free shipping and reserved shipping methods no longer carry over when a customer changes their email address at checkout.
* Security: the checkout no longer tells visitors whether an email address gets a discount, so the shop's list of discounted addresses and company domains cannot be guessed one address at a time.
* Security: visitor IP addresses for the REST rate limit and the audit log can no longer be faked with a Cloudflare header when the request does not come through Cloudflare or a local proxy.
= 1.7.0 =
* Shipping rules have a new choice, "Only". "Only" shows a method to the group and hides it from everybody else, which is what free delivery for one company needs. "Show" goes back to meaning just that: the group is offered the method, and nobody else is affected. Since 1.6.0 "Show" did both, so a group that was shown ordinary methods next to a free one took the ordinary ones away from every other customer. Every "Show" rule saved before this update is changed to "Only" automatically, so the checkout looks the same after updating. To give a group its ordinary methods without reserving them, set those to "Show" and leave the free ones on "Only".
+1 -1
View File
@@ -1,6 +1,6 @@
# Contract
Every hook this plugin offers, and every family hook it answers. Version 1.7.0.
Every hook this plugin offers, and every family hook it answers. Version 1.7.1.
A contract is a name somebody else has to know. Core hooks this plugin merely
listens to — `init`, `admin_menu`, `wp_enqueue_scripts` — are not here;
+1 -1
View File
@@ -1,6 +1,6 @@
# Nolimit Discounts
Version 1.7.0 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
Version 1.7.1 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
Gives a customer a discount, and decides which shipping methods they may
choose, based on the email address they check out with.
+2 -2
View File
@@ -1,6 +1,6 @@
# Releasing
One release a day at most, cut by hand. Version 1.7.0 is current.
One release a day at most, cut by hand. Version 1.7.1 is current.
## The sequence
@@ -45,7 +45,7 @@ is loud, an id that belongs to something else is not.
| --- | --- | --- |
| `ci/static.sh` | syntax, guards, text domain, no secrets, every loaded file ships, one version everywhere, the two compat files agree, no tracked file writes a git identity, the docs still describe this tree | a count on every passing line |
| `ci/selftest.sh` | **the checkers themselves** — see below | `all N assertions passed` |
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 73 assertions passed`, floored at 60 by the caller |
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 89 assertions passed`, floored at 60 by the caller |
| `.github/workflows/compat.yml` | the matrix: PHP 7.4 / 8.3 / 8.4, WP latest and nightly, WC latest and beta | one job per cell |
## The checkers are themselves tested, and that is unusual
+105 -11
View File
@@ -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
View File
@@ -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 '';
}
+86
View File
@@ -318,6 +318,92 @@ WC()->customer->set_billing_email( '[email protected]' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
/* -------------------------------------------------------------- security -- */
// 🔴 The discount follows the address the order is placed under. Up to 1.7.0
// the tracked session email won over the billing email, so a visitor could
// post a discounted address to the tracking endpoint and then check out under
// their own, taking the discount with nothing on the order pointing at it.
wc_edm_cart_for( '[email protected]', array( $fixtures['product_in'] => 1, $fixtures['product_out'] => 1 ) );
WC()->session->set( WC_EDM_Frontend::SESSION_EMAIL_KEY, '[email protected]' );
WC()->cart->calculate_totals();
wc_edm_is( 'a tracked address does not override the billing email', '[email protected]', wc_edm()->frontend->get_customer_email() );
wc_edm_is( 'and buys no discount for an order placed under another address', 0.0, wc_edm_discount_total() );
WC()->customer->set_billing_email( '' );
wc_edm_is( 'the tracked address is still used while there is no billing email', '[email protected]', wc_edm()->frontend->get_customer_email() );
WC()->session->set( WC_EDM_Frontend::SESSION_EMAIL_KEY, null );
// 🔴 WooCommerce caches a package's rates under a hash of the package, which
// did not include who the customer is — so rates filtered for a matched
// address (free shipping, a reserved method) survived a change of email.
WC()->customer->set_billing_email( '[email protected]' );
$wc_edm_pkg_a = wc_edm()->frontend->key_packages_to_match( array( array( 'contents' => array() ) ) );
WC()->customer->set_billing_email( '[email protected]' );
$wc_edm_pkg_b = wc_edm()->frontend->key_packages_to_match( array( array( 'contents' => array() ) ) );
wc_edm_ok(
'a shipping package carries the match, so cached rates do not outlive a change of email',
$wc_edm_pkg_a[0]['wc_edm_match'] > 0 && 0 === $wc_edm_pkg_b[0]['wc_edm_match'],
sprintf( 'matched %s, unmatched %s', var_export( $wc_edm_pkg_a[0]['wc_edm_match'], true ), var_export( $wc_edm_pkg_b[0]['wc_edm_match'], true ) )
);
// 🔴 The tracking endpoint answers anonymous visitors. It used to return
// `matched: true|false`, which told a stranger which addresses and domains the
// shop discounts, one request per guess. The answer must be the same bare
// success for a matched and an unmatched address.
add_filter( 'wp_doing_ajax', '__return_true' );
$wc_edm_die = static function () {
return static function () {
throw new RuntimeException( 'wp_die' );
};
};
add_filter( 'wp_die_ajax_handler', $wc_edm_die );
$wc_edm_track = static function ( $email ) {
$_POST['email'] = $email;
$_REQUEST['nonce'] = wp_create_nonce( 'wc-edm-track-email' );
ob_start();
try {
wc_edm()->frontend->ajax_track_email();
} catch ( RuntimeException $e ) {
unset( $e );
}
return trim( (string) ob_get_clean() );
};
$wc_edm_said_match = $wc_edm_track( '[email protected]' );
wc_edm_is( 'tracking a matched address sets it as the billing email', '[email protected]', WC()->customer->get_billing_email() );
$wc_edm_said_nomatch = $wc_edm_track( '[email protected]' );
wc_edm_is( 'the tracking endpoint answers a matched address with a bare success', '{"success":true}', $wc_edm_said_match );
wc_edm_is( 'and an unmatched one identically', $wc_edm_said_match, $wc_edm_said_nomatch );
remove_filter( 'wp_die_ajax_handler', $wc_edm_die );
remove_filter( 'wp_doing_ajax', '__return_true' );
unset( $_POST['email'], $_REQUEST['nonce'] );
WC()->session->set( WC_EDM_Frontend::SESSION_EMAIL_KEY, null );
WC()->customer->set_billing_email( '[email protected]' );
// 🔴 CF-Connecting-IP is believed only from a proxy. Up to 1.7.0 it was
// believed from anyone, so a direct client chose a new address per request:
// no REST rate limit, and an audit log recording whatever it was told.
$wc_edm_server = $_SERVER;
$wc_edm_ip = static function ( $remote, $header ) {
$_SERVER['REMOTE_ADDR'] = $remote;
if ( null === $header ) {
unset( $_SERVER['HTTP_CF_CONNECTING_IP'] );
} else {
$_SERVER['HTTP_CF_CONNECTING_IP'] = $header;
}
return WC_EDM_Audit_Log::get_client_ip();
};
wc_edm_is( 'a direct client cannot choose its address with CF-Connecting-IP', '203.0.113.7', $wc_edm_ip( '203.0.113.7', '198.51.100.1' ) );
wc_edm_is( 'a request from a Cloudflare edge is attributed to the visitor', '198.51.100.1', $wc_edm_ip( '162.158.1.2', '198.51.100.1' ) );
wc_edm_is( 'and so is one from an IPv6 edge', '198.51.100.1', $wc_edm_ip( '2606:4700::1', '198.51.100.1' ) );
wc_edm_is( 'and one from a local tunnel', '198.51.100.1', $wc_edm_ip( '127.0.0.1', '198.51.100.1' ) );
wc_edm_is( 'and one from a proxy on the private network', '198.51.100.1', $wc_edm_ip( '172.18.0.3', '198.51.100.1' ) );
wc_edm_is( 'an address just outside a Cloudflare range is not a proxy', '173.245.64.1', $wc_edm_ip( '173.245.64.1', '198.51.100.1' ) );
wc_edm_is( 'and the last one inside it is', '198.51.100.1', $wc_edm_ip( '173.245.63.255', '198.51.100.1' ) );
wc_edm_is( 'a malformed header from a proxy falls back to the proxy', '162.158.1.2', $wc_edm_ip( '162.158.1.2', 'not-an-ip' ) );
wc_edm_is( 'no header means REMOTE_ADDR', '203.0.113.7', $wc_edm_ip( '203.0.113.7', null ) );
$_SERVER = $wc_edm_server;
/* ------------------------------------------------------------------ rest -- */
$routes = rest_get_server()->get_routes();
+2 -2
View File
@@ -4,7 +4,7 @@
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
* Version: 1.7.0
* Version: 1.7.1
* Author: Kristian Wind
* Author URI: https://gitea.nolimit.dk/kw
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.7.0' );
define( 'WC_EDM_VERSION', '1.7.1' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );