$product_slug, 'time' => current_time( 'mysql' ), 'expected' => hash_hmac( 'sha256', $body, $api_key ), 'received' => $received, 'bytes' => strlen( $body ), 'status' => $status, ), false ); AffiliateBoxes_Logger::log( sprintf( 'Signature mismatch for %s (%d bytes, status %s). Expected %s, received %s. Continuing because signature checks are advisory; define AFFILIATEBOXES_STRICT_SIGNATURES to fail closed.', $product_slug, strlen( $body ), '' !== $status ? $status : 'none', hash_hmac( 'sha256', $body, $api_key ), $received ) ); return false; } /** * Verify the HMAC-SHA256 signature from an affiliateboxes.com response. * * The remote service signs response bodies using the site's API key as the shared * secret. The signature is sent in the X-Affbox-Signature header as "sha256=". * * @param string $body The response body. * @param string $signature The X-Affbox-Signature header value. * @param string $api_key The shared API key. * @return bool True if the signature is valid. */ public static function verify_signature( string $body, string $signature, string $api_key ): bool { if ( empty( $signature ) || 0 !== strpos( $signature, 'sha256=' ) ) { return false; } $received = substr( $signature, 7 ); $expected = hash_hmac( 'sha256', $body, $api_key ); return hash_equals( $expected, $received ); } /** * Compute an SRI-style SHA-256 hash for content integrity verification. * * @param string $content The content to hash. * @return string The hash in "sha256-" format. */ public static function compute_sri_hash( string $content ): string { return 'sha256-' . base64_encode( hash( 'sha256', $content, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } /** * Verify stored content against its SRI hash. * * @param string $content The stored content. * @param string $sri_hash The stored SRI hash. * @return bool True if the content matches the hash. */ public static function verify_sri_hash( string $content, string $sri_hash ): bool { if ( empty( $sri_hash ) ) { return false; } return hash_equals( self::compute_sri_hash( $content ), $sri_hash ); } /** * Fetch a structured product payload from the /wpbox/{slug} endpoint. * * The endpoint returns JSON of the shape: * * { "html": "...", "css": "...", "version": "..." } * * The payload contains no executable code: markup and stylesheet are separate values, * and both are sanitized before they reach the page. See AffiliateBoxes_Renderer. * * @param string $product_slug The product slug. * @return array{html: string, css: string, version: string} The product payload. * @throws Exception If the endpoint is unreachable, refuses the request, or returns a * payload that fails verification or has an unexpected shape. */ public static function fetch_wpbox_payload( string $product_slug ): array { $settings = get_option( 'affiliateboxes_settings', array() ); $api_key = isset( $settings['api_key'] ) ? $settings['api_key'] : ''; $url = esc_url_raw( 'https://affiliateboxes.com/wpbox/' . rawurlencode( $product_slug ) ); $args = array( 'timeout' => 15, 'headers' => array( 'Accept' => 'application/json', 'X-Domain' => get_option( 'home' ), 'X-Affbox-Format' => 'wpbox.v1', 'X-Affbox-Key-Id' => self::key_id( $api_key ), 'X-Affbox-Plugin' => defined( 'AFFILIATEBOXES_VERSION' ) ? AFFILIATEBOXES_VERSION : '', ), ); /** * Filters the arguments used for HTTP requests to affiliateboxes.com. * * Useful, for example, when the site resolves affiliateboxes.com to a local/loopback * address on the same server (bypassing Cloudflare) and the direct connection is plain * HTTP or presents a certificate that won't validate - set 'sslverify' to false in that * case. Only do this when you control the network path to the target host. * * @param array $args The wp_remote_get()/wp_remote_post() arguments. * @param string $url The request URL. */ $response = wp_remote_get( $url, apply_filters( 'affiliateboxes_http_request_args', $args, $url ) ); if ( is_wp_error( $response ) ) { throw new Exception( esc_html( $response->get_error_message() ) ); } $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { throw new Exception( sprintf( /* translators: %d: HTTP status code returned by affiliateboxes.com. */ esc_html__( 'affiliateboxes.com returned HTTP %d for this product.', 'affiliateboxes' ), (int) $status_code ) ); } $body = wp_remote_retrieve_body( $response ); if ( ! self::passes_signature_check( $response, $body, $api_key, $product_slug ) ) { // In strict mode a mismatch rejects the payload outright. Otherwise it has been // logged and surfaced in the admin, and the payload is still used - see // passes_signature_check() for why verification is advisory by default. if ( self::signatures_are_strict() ) { throw new Exception( esc_html__( 'Content signature verification failed. The response may have been tampered with.', 'affiliateboxes' ) ); } } $payload = json_decode( $body, true ); if ( ! is_array( $payload ) || ! isset( $payload['html'] ) || ! is_string( $payload['html'] ) ) { throw new Exception( esc_html__( 'affiliateboxes.com returned an unexpected response for this product.', 'affiliateboxes' ) ); } return array( 'html' => $payload['html'], 'css' => isset( $payload['css'] ) && is_string( $payload['css'] ) ? $payload['css'] : '', 'version' => isset( $payload['version'] ) ? sanitize_text_field( (string) $payload['version'] ) : '', ); } /** * Fetch a product's display payload and its integrity hash. * * @param string $product_slug The product slug. * @return array{html: string, css: string, version: string, sri_hash: string} * @throws Exception If the endpoint yields no usable content. */ public static function build_product_payload( string $product_slug ): array { $payload = self::fetch_wpbox_payload( $product_slug ); return array( 'html' => $payload['html'], 'css' => $payload['css'], 'version' => $payload['version'], 'sri_hash' => self::compute_sri_hash( $payload['html'] . $payload['css'] ), ); } /** * Delete a product by its slug. * * @param string $product_slug The product slug. */ public static function delete_product_by_slug( string $product_slug ): void { $query = new WP_Query( array( 'post_type' => 'affiliate_product', 'meta_key' => '_affprod_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $product_slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'posts_per_page' => 1, 'post_status' => 'any', ) ); if ( $query->have_posts() ) { wp_delete_post( $query->posts[0]->ID, true ); } } /** * Insert or update a product by its slug. * * @param string $product_slug The product slug. * @param string|null $product_title The product title. * @param string|null $product_type The product type. * @param string|null $product_identifier The product identifier. * @return int The post ID. * @throws Exception If insertion fails. */ public static function upsert_product_by_slug( string $product_slug, ?string $product_title = null, ?string $product_type = null, ?string $product_identifier = null ): int { $query = new WP_Query( array( 'post_type' => 'affiliate_product', 'meta_key' => '_affprod_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $product_slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'posts_per_page' => 1, 'post_status' => 'any', ) ); $result = self::build_product_payload( $product_slug ); $payload_meta = array( '_affprod_html' => $result['html'], '_affprod_css' => $result['css'], '_affprod_version' => $result['version'], '_affprod_sri_hash' => $result['sri_hash'], ); if ( ! $query->have_posts() ) { $post_id = wp_insert_post( array( 'post_title' => $product_title ? sanitize_text_field( $product_title ) : ( 'Product: ' . $product_slug ), 'post_type' => 'affiliate_product', 'post_status' => 'publish', 'meta_input' => array_merge( $payload_meta, array( '_affprod_slug' => $product_slug, '_affprod_type' => $product_type, '_affprod_identifier' => $product_identifier, '_affprod_last_synced' => current_time( 'mysql' ), ) ), ), true ); if ( is_wp_error( $post_id ) ) { throw new Exception( esc_html( $post_id->get_error_message() ) ); } } else { $post_id = $query->posts[0]->ID; foreach ( $payload_meta as $meta_key => $meta_value ) { update_post_meta( $post_id, $meta_key, $meta_value ); } update_post_meta( $post_id, '_affprod_type', $product_type ); update_post_meta( $post_id, '_affprod_identifier', $product_identifier ); update_post_meta( $post_id, '_affprod_last_synced', current_time( 'mysql' ) ); wp_update_post( array( 'ID' => $post_id, 'post_title' => $product_title ? sanitize_text_field( $product_title ) : ( 'Product: ' . $product_slug ), ) ); } return (int) $post_id; } }