JezK
Edit File: class-rest-api-guard.php
<?php /** * REST Purchase Guard (Free) * * Central validation for purchase events coming through the public REST * tracking endpoints. In the free plugin the built-in endpoint is Facebook * (`pys-facebook/v1/event`); add-ons (Pinterest, Bing, Reddit) register their * own endpoints. This guard covers them all. * * The problem it solves: the /event REST endpoints are, by design, reachable * by anonymous visitors (tracking must work on cached pages). Historically the * only gate was an Origin/Referer check, which is trivially spoofable. That let * anyone POST a purchase event referencing any / non-existent order and inject * arbitrary metrics. * * This guard hooks the WordPress core `rest_pre_dispatch` filter — a single * choke point that runs for every REST request before the route handler, so it * covers the built-in Facebook endpoint AND separate add-on endpoints WITHOUT * editing those add-ons. * * Scope (Phase 1): * - Every event: its event_slug must be a slug the system actually * registers (built-ins + 'custom_event'); arbitrary/injected slugs are * rejected. * - Purchase events (by slug or platform event name): must reference a real, * valid order (existence + status, plus a soft order_key binding when the * key is provided). * * Scope (Phase 2): * - Purchase / CompletePayment: the monetary value is overwritten with a * server-authoritative amount derived from the order, so a tampered client * value cannot inflate metrics. Only the value is touched. * * @package PixelYourSite */ namespace PixelYourSite; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } if ( ! class_exists( 'PixelYourSite\\RestAPIGuard' ) ) { class RestAPIGuard { /** * @var RestAPIGuard|null */ private static $instance = null; /** * REST namespaces we guard (built-in + free-ecosystem add-ons). * * @var string[] */ private $namespaces = array( 'pys-facebook', 'pys-pinterest', 'pys-bing', 'pys-reddit' ); /** * PYS event slugs that MUST be backed by a real order. * * @var string[] */ private $purchase_slugs = array( 'woo_purchase', 'edd_purchase', 'edd_complete_payment' ); /** * Platform purchase event names that MUST be backed by a real order, * matched case-insensitively. Facebook => 'Purchase'. ('checkout' is * intentionally omitted so a checkout without an order is not rejected; it * is still validated whenever it carries one.) * * @var string[] */ private $purchase_names = array( 'Purchase', 'CompletePayment' ); /** * Singleton accessor. * * @return RestAPIGuard */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_filter( 'rest_pre_dispatch', array( $this, 'guard' ), 10, 3 ); } /** * Validate purchase REST requests before they reach the route handler. * * @param mixed $result Response to short-circuit with, or null to continue. * @param \WP_REST_Server $server Server instance (unused). * @param \WP_REST_Request $request Current request. * @return mixed WP_Error to reject, otherwise the untouched $result (continue). */ public function guard( $result, $server, $request ) { // Respect a short-circuit already produced by another integration. if ( null !== $result ) { return $result; } // Fast path: rest_pre_dispatch runs for EVERY REST request site-wide, // so bail out immediately for anything that is not one of our /event // endpoints before doing any further work. if ( ! $this->is_guarded_route( $request ) ) { return $result; } // Master switch for site owners / edge setups. if ( ! apply_filters( 'pys_rest_purchase_guard_enabled', true ) ) { return $result; } $woo_order = $this->to_id( $request->get_param( 'woo_order' ) ); $edd_order = $this->to_id( $request->get_param( 'edd_order' ) ); $event_slug = trim( (string) $request->get_param( 'event_slug' ) ); $event_name = (string) $request->get_param( 'event' ); // Validate the event slug when one is present: it must correspond to a // slug the system actually registers (built-ins + 'custom_event'). // Arbitrary / injected slugs are rejected outright. Case-sensitive: // system slugs are canonical lowercase, so any other casing is invalid. // // The browser sends event_slug (= the event's e_id) for every event, so // this applies to all real traffic. The `'' !==` guard is a safety net // for edge/add-on requests that omit the slug (validation is skipped // rather than rejecting them outright). if ( '' !== $event_slug && apply_filters( 'pys_rest_validate_event_slug', true, $request ) ) { if ( ! in_array( $event_slug, $this->valid_event_slugs(), true ) ) { return $this->reject( 'invalid_event_slug', 'Unknown event slug.', $request, 0 ); } } $has_order = ( $woo_order > 0 || $edd_order > 0 ); // Purchase detection is case-insensitive for both the PYS slug and the // platform event name. A name-detected "Purchase" is treated exactly // like an ecommerce slug (it must be backed by a real order). $is_purchase = $this->matches_ci( $event_slug, $this->purchase_slugs ) || $this->matches_ci( $event_name, $this->purchase_names ); // Not order-related and not a purchase — leave other events untouched. if ( ! $has_order && ! $is_purchase ) { return $result; } // A purchase event that carries no order reference cannot be validated. if ( $is_purchase && ! $has_order ) { if ( apply_filters( 'pys_rest_require_order_for_purchase', true, $event_slug, $request ) ) { return $this->reject( 'missing_order', 'Purchase event without an order reference.', $request, 0 ); } return $result; } $order_key = $this->to_key( $request->get_param( 'order_key' ) ); if ( $woo_order > 0 ) { $verdict = $this->validate_woo( $woo_order, $order_key, $request ); } else { $verdict = $this->validate_edd( $edd_order, $order_key, $request ); } if ( is_wp_error( $verdict ) ) { return $verdict; } // Phase 2: enforce a server-authoritative value for Purchase / // CompletePayment so a tampered client value cannot inflate metrics. if ( $this->matches_ci( $event_name, $this->purchase_names ) && apply_filters( 'pys_rest_enforce_purchase_value', true, $request ) ) { $this->enforce_authoritative_value( $request, $woo_order, $edd_order ); } return $result; // Valid — let the request continue to its handler. } /** * Overwrite the event value with a server-derived, order-authoritative * amount. Only the monetary value is touched: * - data['value'] is set directly (Facebook reads it as-is); * - if data['contents'] is present, item prices are rewritten so their * quantity*price total equals the authoritative value (TikTok derives * the value from contents, ignoring data['value']). content_ids and * quantities are preserved. * * @param \WP_REST_Request $request * @param int $woo_order * @param int $edd_order * @return void */ private function enforce_authoritative_value( $request, $woo_order, $edd_order ) { $value = $this->authoritative_value( $woo_order, $edd_order ); if ( null === $value ) { return; } $data = $request->get_param( 'data' ); if ( is_string( $data ) ) { $decoded = json_decode( $data, true ); $data = is_array( $decoded ) ? $decoded : array(); } if ( ! is_array( $data ) ) { $data = array(); } // Direct value (Facebook Purchase). $data['value'] = $value; // Contents-derived value (TikTok CompletePayment): the value is the sum // of quantity*price over items, so make that sum equal the authoritative // value. Scale item prices proportionally — an already-correct contents // array stays untouched (factor 1) and only a tampered one is corrected, // preserving per-item proportions. Fall back to an even per-unit split // only when current prices sum to zero (nothing to scale). if ( ! empty( $data['contents'] ) && is_array( $data['contents'] ) ) { $current_sum = 0; $total_qty = 0; foreach ( $data['contents'] as $c ) { $qty = isset( $c['quantity'] ) ? (float) $c['quantity'] : 0; $price = isset( $c['price'] ) ? (float) $c['price'] : 0; $current_sum += $qty * $price; $total_qty += $qty; } if ( $current_sum > 0 ) { $factor = $value / $current_sum; foreach ( $data['contents'] as $k => $c ) { if ( isset( $c['price'] ) ) { $data['contents'][ $k ]['price'] = (float) $c['price'] * $factor; } } } elseif ( $total_qty > 0 ) { $unit_price = $value / $total_qty; foreach ( $data['contents'] as $k => $c ) { if ( isset( $c['quantity'] ) ) { $data['contents'][ $k ]['price'] = $unit_price; } } } } $request->set_param( 'data', wp_json_encode( $data ) ); } /** * Compute the order-authoritative value using the SAME helpers and options * the plugin uses when it builds the purchase event server-side, so the * result matches a legitimate event. * * @param int $woo_order * @param int $edd_order * @return float|null Null when it cannot be determined. */ private function authoritative_value( $woo_order, $edd_order ) { if ( $woo_order > 0 && function_exists( 'wc_get_order' ) && function_exists( 'PixelYourSite\\getWooEventValueOrder' ) ) { $order = wc_get_order( $woo_order ); if ( $order instanceof \WC_Order ) { $option = PYS()->getOption( 'woo_purchase_value_option' ); $global = PYS()->getOption( 'woo_purchase_value_global', 0 ); $percent = PYS()->getOption( 'woo_purchase_value_percent', 100 ); return (float) getWooEventValueOrder( $option, $order, $global, $percent ); } } if ( $edd_order > 0 && function_exists( 'edd_get_payment_amount' ) && function_exists( 'PixelYourSite\\getEddEventValue' ) ) { $option = PYS()->getOption( 'edd_purchase_value_option' ); $global = PYS()->getOption( 'edd_purchase_value_global', 0 ); $percent = PYS()->getOption( 'edd_purchase_value_percent', 100 ); $amount = (float) edd_get_payment_amount( $edd_order ); return (float) getEddEventValue( $option, $amount, $global, $percent ); } return null; } /** * WooCommerce order validation. * * @param int $order_id Claimed order ID. * @param string $order_key Submitted order key ('' if not provided). * @param \WP_REST_Request $request Current request. * @return true|\WP_Error */ private function validate_woo( $order_id, $order_key, $request ) { // Woo not available in this context — nothing to validate against. if ( ! function_exists( 'wc_get_order' ) ) { return true; } $order = wc_get_order( $order_id ); if ( ! $order instanceof \WC_Order ) { return $this->reject( 'invalid_order', 'WooCommerce order does not exist.', $request, $order_id ); } // Mirror the plugin's own rule (EventsWoo::getPurchaseOrderId): the // browser purchase is not fired for statuses listed in // `woo_order_purchase_disabled_status`. Values are stored WITH the // "wc-" prefix and compared against "wc-" . get_status( 'edit' ). $disabled = (array) apply_filters( 'pys_rest_woo_blocked_statuses', (array) PYS()->getOption( 'woo_order_purchase_disabled_status' ) ); $status = 'wc-' . $order->get_status( 'edit' ); if ( in_array( $status, $disabled, true ) ) { return $this->reject( 'invalid_order_status', 'WooCommerce order status is not allowed.', $request, $order_id ); } // order_key is validated softly: only when the client actually sent one. if ( '' !== $order_key ) { $real_key = (string) $order->get_order_key(); if ( '' === $real_key || ! hash_equals( $real_key, $order_key ) ) { return $this->reject( 'invalid_order_key', 'WooCommerce order key mismatch.', $request, $order_id ); } } return true; } /** * Easy Digital Downloads order validation. * * @param int $order_id Claimed payment/order ID. * @param string $order_key Submitted payment key ('' if not provided). * @param \WP_REST_Request $request Current request. * @return true|\WP_Error */ private function validate_edd( $order_id, $order_key, $request ) { if ( ! function_exists( 'edd_get_payment' ) ) { return true; } $payment = edd_get_payment( $order_id ); if ( empty( $payment ) || empty( $payment->ID ) ) { return $this->reject( 'invalid_order', 'EDD order does not exist.', $request, $order_id ); } $status = function_exists( 'edd_get_payment_status' ) ? edd_get_payment_status( $payment ) : ''; $blocked = apply_filters( 'pys_rest_edd_blocked_statuses', array( 'failed', 'abandoned', 'revoked', 'cancelled' ) ); if ( $status && in_array( $status, (array) $blocked, true ) ) { return $this->reject( 'invalid_order_status', 'EDD order status is not allowed.', $request, $order_id ); } if ( '' !== $order_key && function_exists( 'edd_get_payment_key' ) ) { $real_key = (string) edd_get_payment_key( $order_id ); if ( '' === $real_key || ! hash_equals( $real_key, $order_key ) ) { return $this->reject( 'invalid_order_key', 'EDD order key mismatch.', $request, $order_id ); } } return true; } /** * Is this one of the guarded /event routes? * * @param \WP_REST_Request $request * @return bool */ private function is_guarded_route( $request ) { $route = (string) $request->get_route(); // e.g. /pys-facebook/v1/event foreach ( $this->namespaces as $ns ) { if ( 0 === strpos( $route, '/' . $ns . '/' ) && '/event' === substr( $route, -6 ) ) { return true; } } return false; } /** * The full set of event slugs the system can legitimately fire through the * guarded endpoints. Built once and cached per request. * * Uses the same superset as PRO (a whitelist of known PYS slugs); extra * PRO-only slugs are harmless in free (they are still known PYS slugs, not * arbitrary injections). Covers built-in factories plus the shared * 'custom_event' slug used by ALL user custom events. * * Extend via the `pys_rest_valid_event_slugs` filter (e.g. for add-ons that * introduce new slugs). * * @return string[] */ private function valid_event_slugs() { static $slugs = null; if ( null !== $slugs ) { return $slugs; } $builtin = array( // Standard 'init_event', // PageView // User custom events (all share this slug) 'custom_event', // Automatic events 'automatic_event_internal_link', 'automatic_event_outbound_link', 'automatic_event_video', 'automatic_event_tel_link', 'automatic_event_email_link', 'automatic_event_form', 'automatic_event_signup', 'automatic_event_login', 'automatic_event_download', 'automatic_event_comment', 'automatic_event_adsense', 'automatic_event_scroll', 'automatic_event_time_on_page', 'automatic_event_404', 'automatic_event_search', 'automatic_event_rage_click', 'automatic_event_video_speed', // WooCommerce 'woo_affiliate', 'woo_add_to_cart_on_button_click', 'woo_add_to_cart_on_cart_page', 'woo_add_to_cart_on_checkout_page', 'woo_select_content_category', 'woo_select_content_single', 'woo_select_content_search', 'woo_select_content_shop', 'woo_select_content_tag', 'woo_paypal', 'woo_remove_from_cart', 'woo_initiate_checkout_progress_f', 'woo_initiate_checkout_progress_l', 'woo_initiate_checkout_progress_e', 'woo_initiate_checkout_progress_o', 'woo_initiate_set_checkout_option', 'woo_purchase', 'woo_complete_payment', 'woo_ReturningCustomer', 'woo_FirstTimeBuyer', 'woo_frequent_shopper', 'woo_vip_client', 'woo_big_whale', 'woo_view_content', 'woo_view_category', 'woo_view_item_list', 'woo_view_cart', 'woo_view_item_list_single', 'woo_view_item_list_search', 'woo_view_item_list_shop', 'woo_view_item_list_tag', 'woo_initiate_checkout', 'woo_start_trial', 'woo_subscription_created', 'woo_subscription_renewal', 'woo_subscription_expired', 'woo_subscription_canceled', 'woo_refund', // Easy Digital Downloads 'edd_add_to_cart_on_button_click', 'edd_add_to_cart_on_checkout_page', 'edd_purchase', 'edd_complete_payment', 'edd_initiate_checkout', 'edd_remove_from_cart', 'edd_view_category', 'edd_view_content', 'edd_vip_client', 'edd_big_whale', 'edd_frequent_shopper', 'edd_start_trial', 'edd_subscription_created', 'edd_subscription_renewal', 'edd_subscription_expired', 'edd_subscription_canceled', 'edd_license_created', 'edd_license_upgrade', 'edd_license_renewal', 'edd_license_expired', 'edd_refund', // Facebook Dynamic Products feed 'fdp_purchase', 'fdp_add_to_cart', 'fdp_view_content', 'fdp_view_category', // CartFlows 'wcf_view_content', 'wcf_add_to_cart_on_next_step_click', 'wcf_add_to_cart_on_bump_click', 'wcf_remove_from_cart_on_bump_click', 'wcf_page', 'wcf_step_page', 'wcf_bump', 'wcf_lead', ); $slugs = (array) apply_filters( 'pys_rest_valid_event_slugs', $builtin ); return $slugs; } /** * Case-insensitive membership test. * * @param mixed $value * @param string[] $list * @return bool */ private function matches_ci( $value, array $list ) { $value = strtolower( trim( (string) $value ) ); if ( '' === $value ) { return false; } foreach ( $list as $item ) { if ( $value === strtolower( (string) $item ) ) { return true; } } return false; } /** * Normalize an order id param to a positive int (mirrors sanitize_order_id). * * @param mixed $value * @return int */ private function to_id( $value ) { if ( is_string( $value ) ) { $value = trim( $value ); if ( '' === $value || 'null' === $value || 'undefined' === $value ) { return 0; } } return is_numeric( $value ) ? max( 0, (int) $value ) : 0; } /** * Normalize an order key param; empty when not meaningfully provided. * * @param mixed $value * @return string */ private function to_key( $value ) { if ( ! is_string( $value ) ) { return ''; } $value = sanitize_text_field( trim( $value ) ); if ( 'null' === $value || 'undefined' === $value || '0' === $value ) { return ''; } return $value; } /** * Build a 403 WP_Error and log the rejection. * * @param string $code * @param string $message * @param \WP_REST_Request $request * @param int $order_id * @return \WP_Error */ private function reject( $code, $message, $request, $order_id ) { $route = method_exists( $request, 'get_route' ) ? $request->get_route() : ''; if ( function_exists( 'PixelYourSite\\PYS' ) && PYS() && PYS()->getLog() ) { PYS()->getLog()->debug( 'RestAPIGuard rejected event', array( 'code' => $code, 'route' => $route, 'order_id' => $order_id, 'ip' => method_exists( PYS(), 'get_user_ip' ) ? PYS()->get_user_ip() : '', ) ); } return new \WP_Error( 'pys_rest_' . $code, $message, array( 'status' => 403 ) ); } } } // class_exists guard