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]>
248 lines
6.4 KiB
PHP
248 lines
6.4 KiB
PHP
<?php
|
|
/**
|
|
* Audit log for REST API activity.
|
|
*
|
|
* Records every write to the wc-edm REST API and every rejected request,
|
|
* so unauthorized access attempts are visible after the fact.
|
|
*
|
|
* @package WC_Email_Discount_Manager
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Writes and maintains the audit log.
|
|
*/
|
|
class WC_EDM_Audit_Log {
|
|
|
|
/**
|
|
* Cron hook used for pruning old entries.
|
|
*/
|
|
const CRON_HOOK = 'wc_edm_prune_audit_log';
|
|
|
|
/**
|
|
* Hook the daily prune job.
|
|
*/
|
|
public function __construct() {
|
|
add_action( self::CRON_HOOK, array( __CLASS__, 'prune' ) );
|
|
add_action( 'init', array( __CLASS__, 'schedule_prune' ) );
|
|
}
|
|
|
|
/**
|
|
* Make sure the daily prune job is scheduled.
|
|
*/
|
|
public static function schedule_prune() {
|
|
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
|
|
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the scheduled prune job (used on deactivation).
|
|
*/
|
|
public static function unschedule_prune() {
|
|
$timestamp = wp_next_scheduled( self::CRON_HOOK );
|
|
|
|
if ( $timestamp ) {
|
|
wp_unschedule_event( $timestamp, self::CRON_HOOK );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether audit logging is enabled.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function is_enabled() {
|
|
/**
|
|
* Filter whether REST activity is written to the audit log.
|
|
*
|
|
* @param bool $enabled Default true.
|
|
*/
|
|
return (bool) apply_filters( 'wc_edm_audit_log_enabled', true );
|
|
}
|
|
|
|
/**
|
|
* How many days entries are kept.
|
|
*
|
|
* @return int
|
|
*/
|
|
public static function retention_days() {
|
|
/**
|
|
* Filter the audit log retention period in days.
|
|
*
|
|
* @param int $days Default 30.
|
|
*/
|
|
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.
|
|
*
|
|
* 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() {
|
|
$remote = ! empty( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
|
|
$remote = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '';
|
|
$ip = $remote;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter the resolved client IP used for logging and rate limiting.
|
|
*
|
|
* @param string $ip Resolved IP address.
|
|
*/
|
|
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.
|
|
*
|
|
* @param string $outcome One of allowed|denied|rate_limited.
|
|
* @param string $method HTTP method.
|
|
* @param string $route REST route.
|
|
* @param int $status HTTP status code.
|
|
* @param array $details Optional extra context.
|
|
* @return void
|
|
*/
|
|
public static function record( $outcome, $method, $route, $status = 0, $details = array() ) {
|
|
if ( ! self::is_enabled() ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) {
|
|
$outcome = 'allowed';
|
|
}
|
|
|
|
$user = wp_get_current_user();
|
|
|
|
WC_EDM_DB::insert_audit_entry(
|
|
array(
|
|
'user_id' => $user ? (int) $user->ID : 0,
|
|
'user_login' => $user && $user->user_login ? $user->user_login : '',
|
|
'ip' => self::get_client_ip(),
|
|
'method' => strtoupper( substr( (string) $method, 0, 10 ) ),
|
|
'route' => substr( (string) $route, 0, 191 ),
|
|
'status' => (int) $status,
|
|
'outcome' => $outcome,
|
|
'details' => ! empty( $details ) ? wp_json_encode( $details ) : null,
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete entries older than the retention period.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function prune() {
|
|
WC_EDM_DB::prune_audit_log( self::retention_days() );
|
|
}
|
|
}
|