* X-A1-Tools-Signature: * Body is empty for now. * 2. We check timestamp tolerance (±300s, replay protection). * 3. We hash_hmac the same inputs with our stored secret and * hash_equals to verify (constant-time compare). * 4. On match, call a1tools_clear_cache() — which deletes every * transient written by this plugin. * * Secret storage: `a1tools_invalidate_secret` WP option. Generated on * first run after 2.5.26 install/upgrade and registered with the * central server via a1tools_register_invalidation_with_server() in * the main plugin file's upgrade routine. * * @package A1_Tools * @since 2.5.26 */ // Prevent direct file access. if ( ! defined( 'ABSPATH' ) ) { exit; } class A1Tools_Cache_Invalidation_REST { /** * Plus/minus tolerance for the request timestamp, in seconds. Five * minutes is generous enough to survive clock drift between the * central server and the customer site, while short enough that * captured signatures stop working before they're useful for * anything. */ const TIMESTAMP_TOLERANCE = 300; /** * Wire the REST route at plugin load time. */ public static function init() { add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); } /** * Register the POST route. permission_callback returns true * unconditionally because the HMAC check IS the auth — we don't * want WordPress to gate it by user capability. */ public static function register_routes() { register_rest_route( 'a1-tools/v1', '/invalidate-cache', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'handle' ), 'permission_callback' => '__return_true', ) ); } /** * Handle the inbound invalidation ping. * * @param WP_REST_Request $request * @return array|WP_Error */ public static function handle( WP_REST_Request $request ) { $secret = (string) get_option( 'a1tools_invalidate_secret', '' ); if ( '' === $secret ) { return new WP_Error( 'not_registered', 'This site has no invalidation secret configured.', array( 'status' => 401 ) ); } // WordPress lowercases + underscores incoming header names. $timestamp = $request->get_header( 'x_a1_tools_timestamp' ); $signature = $request->get_header( 'x_a1_tools_signature' ); if ( null === $timestamp || null === $signature || '' === $timestamp || '' === $signature ) { return new WP_Error( 'missing_signature', 'Missing X-A1-Tools-Timestamp or X-A1-Tools-Signature header.', array( 'status' => 401 ) ); } // Replay protection: reject signatures that are stale (or // implausibly future-dated due to a misconfigured central // server clock). $ts_int = (int) $timestamp; if ( abs( time() - $ts_int ) > self::TIMESTAMP_TOLERANCE ) { return new WP_Error( 'stale_timestamp', 'Signature timestamp is outside the allowed tolerance.', array( 'status' => 401 ) ); } // HMAC over $timestamp . $body. Body is currently always empty // for invalidation pings; signing the timestamp alone would // allow trivial replay across body content, so we keep the // concat in case we add body fields later (e.g. "invalidate // only the variables transient, not the city_pages one"). $body = (string) $request->get_body(); $expected = hash_hmac( 'sha256', $timestamp . $body, $secret ); if ( ! hash_equals( $expected, $signature ) ) { return new WP_Error( 'invalid_signature', 'Signature did not verify.', array( 'status' => 401 ) ); } // Verified — clear every a1tools_* transient. The next page // render that needs a value will hit the central API once and // refill them. CDN edge cache (5 min) is a separate layer; the // invalidation can't reach there, but its TTL is short enough // that this isn't a problem in practice. if ( function_exists( 'a1tools_clear_cache' ) ) { a1tools_clear_cache(); } return array( 'success' => true, 'cleared_at' => time(), ); } } A1Tools_Cache_Invalidation_REST::init();