diff --git a/changelog.txt b/changelog.txt index 37e6ce54e..adcd18286 100644 --- a/changelog.txt +++ b/changelog.txt @@ -10,6 +10,8 @@ * Fix - Advanced Card Processing gateway becomes invisible post-plugin update unless admin pages are accessed once #1432 * Fix - Incompatibility with WooCommerce One Page Checkout (or similar use cases) in Version 2.1.0 #1473 * Fix - Prevent Repetitive Token Migration and Database Overload After 2.1.0 Update #1461 +* Fix - Onboarding from connection page with CSRF parameter manipulates email and merchant id fields #1502 +* Fix - Do not complete non-checkout button orders via webhooks #1513 * Enhancement - Remove feature flag requirement for express cart/checkout block integration #1483 * Enhancement - Add notice when shop currency is unsupported #1433 * Enhancement - Improve ACDC error message when empty fields #1360 diff --git a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php index ad138cc75..f2c64c73a 100644 --- a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php +++ b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php @@ -211,6 +211,15 @@ class PurchaseUnit { return $this->custom_id; } + /** + * Sets the custom ID. + * + * @param string $custom_id The value to set. + */ + public function set_custom_id( string $custom_id ): void { + $this->custom_id = $custom_id; + } + /** * Returns the invoice id. * diff --git a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php index a955dcf23..af8bae174 100644 --- a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php +++ b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php @@ -127,4 +127,17 @@ class PartnerReferralsData { ) ); } + + /** + * Append the validation token to the return_url + * + * @param array $data The referral data. + * @param string $token The token to be appended. + * @return array + */ + public function append_onboarding_token( array $data, string $token ): array { + $data['partner_config_override']['return_url'] = + add_query_arg( 'ppcpToken', $token, $data['partner_config_override']['return_url'] ); + return $data; + } } diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index 686b87dd8..39a848433 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -258,6 +258,12 @@ class CreateOrderEndpoint implements EndpointInterface { } else { $this->purchase_unit = $this->purchase_unit_factory->from_wc_cart( null, $this->handle_shipping_in_paypal ); + // Do not allow completion by webhooks when started via non-checkout buttons, + // it is needed only for some APMs in checkout. + if ( in_array( $data['context'], array( 'product', 'cart', 'cart-block' ), true ) ) { + $this->purchase_unit->set_custom_id( '' ); + } + // The cart does not have any info about payment method, so we must handle free trial here. if ( ( in_array( $payment_method, array( CreditCardGateway::ID, CardButtonGateway::ID ), true ) diff --git a/modules/ppcp-onboarding/src/Helper/OnboardingUrl.php b/modules/ppcp-onboarding/src/Helper/OnboardingUrl.php new file mode 100644 index 000000000..3365882ad --- /dev/null +++ b/modules/ppcp-onboarding/src/Helper/OnboardingUrl.php @@ -0,0 +1,318 @@ +cache = $cache; + $this->cache_key_prefix = $cache_key_prefix; + $this->user_id = $user_id; + } + + /** + * Validates the token, if it's valid then delete it. + * If it's invalid don't delete it, to prevent malicious requests from invalidating the token. + * + * @param Cache $cache The cache object where the URL is stored. + * @param string $onboarding_token The token to validate. + * @param int $user_id User ID to associate the link with. + * @return bool + */ + public static function validate_token_and_delete( Cache $cache, string $onboarding_token, int $user_id ): bool { + if ( ! $onboarding_token ) { + return false; + } + + $token_data = json_decode( UrlHelper::url_safe_base64_decode( $onboarding_token ) ?: '', true ); + + if ( ! $token_data ) { + return false; + } + + if ( ! isset( $token_data['u'] ) || ! isset( $token_data['k'] ) ) { + return false; + } + + if ( $token_data['u'] !== $user_id ) { + return false; + } + + $onboarding_url = new self( $cache, $token_data['k'], $token_data['u'] ); + + if ( ! $onboarding_url->load() ) { + return false; + } + + if ( ( $onboarding_url->token() ?: '' ) !== $onboarding_token ) { + return false; + } + + $onboarding_url->delete(); + return true; + } + + /** + * Load cached data if is valid and initialize object. + * + * @return bool + */ + public function load(): bool { + if ( ! $this->cache->has( $this->cache_key() ) ) { + return false; + } + + $cached_data = $this->cache->get( $this->cache_key() ); + + if ( ! $this->validate_cache_data( $cached_data ) ) { + return false; + } + + $this->secret = $cached_data['secret']; + $this->time = $cached_data['time']; + $this->url = $cached_data['url']; + + return true; + } + + /** + * Initializes the object + * + * @return void + */ + public function init(): void { + try { + $this->secret = bin2hex( random_bytes( 16 ) ); + } catch ( \Exception $e ) { + $this->secret = wp_generate_password( 16 ); + } + + $this->time = time(); + $this->url = null; + } + + /** + * Validates data from cache + * + * @param array $cache_data The data retrieved from the cache. + * @return bool + */ + private function validate_cache_data( $cache_data ): bool { + if ( ! is_array( $cache_data ) ) { + return false; + } + + if ( + ! ( $cache_data['user_id'] ?? false ) + || ! ( $cache_data['hash_check'] ?? false ) + || ! ( $cache_data['secret'] ?? false ) + || ! ( $cache_data['time'] ?? false ) + || ! ( $cache_data['url'] ?? false ) + ) { + return false; + } + + if ( $cache_data['user_id'] !== $this->user_id ) { + return false; + } + + // Detect if salt has changed. + if ( $cache_data['hash_check'] !== wp_hash( '' ) ) { + return false; + } + + // If we want we can also validate time for expiration eventually. + + return true; + } + + /** + * Returns the URL + * + * @return string + * @throws RuntimeException Throws in case the URL isn't initialized. + */ + public function get(): string { + if ( null === $this->url ) { + throw new RuntimeException( 'Object not initialized.' ); + } + return $this->url; + } + + /** + * Returns the Token + * + * @return string + * @throws RuntimeException Throws in case the object isn't initialized. + */ + public function token(): string { + if ( + null === $this->secret + || null === $this->time + || null === $this->user_id + ) { + throw new RuntimeException( 'Object not initialized.' ); + } + + // Trim the hash to make sure the token isn't too long. + $hash = substr( + wp_hash( + implode( + '|', + array( + $this->cache_key_prefix, + $this->user_id, + $this->secret, + $this->time, + ) + ) + ), + 0, + 32 + ); + + $token = wp_json_encode( + array( + 'k' => $this->cache_key_prefix, + 'u' => $this->user_id, + 'h' => $hash, + ) + ); + + if ( ! $token ) { + throw new RuntimeException( 'Unable to generate token.' ); + } + + return UrlHelper::url_safe_base64_encode( $token ); + } + + /** + * Sets the URL + * + * @param string $url The URL to store in the cache. + * @return void + */ + public function set( string $url ): void { + $this->url = $url; + } + + /** + * Persists the URL and related data in cache + * + * @return void + */ + public function persist(): void { + if ( + null === $this->secret + || null === $this->time + || null === $this->user_id + || null === $this->url + ) { + return; + } + + $this->cache->set( + $this->cache_key(), + array( + 'hash_check' => wp_hash( '' ), // To detect if salt has changed. + 'secret' => $this->secret, + 'time' => $this->time, + 'user_id' => $this->user_id, + 'url' => $this->url, + ), + $this->cache_ttl + ); + } + + /** + * Deletes the token from cache + * + * @return void + */ + public function delete(): void { + $this->cache->delete( $this->cache_key() ); + } + + /** + * Returns the compiled cache key + * + * @return string + */ + private function cache_key(): string { + return implode( '_', array( $this->cache_key_prefix, $this->user_id ) ); + } + +} diff --git a/modules/ppcp-onboarding/src/Helper/UrlHelper.php b/modules/ppcp-onboarding/src/Helper/UrlHelper.php new file mode 100644 index 000000000..b50402ead --- /dev/null +++ b/modules/ppcp-onboarding/src/Helper/UrlHelper.php @@ -0,0 +1,42 @@ +cache->has( $environment . '-' . $product ) ) { - return $this->cache->get( $environment . '-' . $product ); + $cache_key = $environment . '-' . $product; + + $onboarding_url = new OnboardingUrl( $this->cache, $cache_key, get_current_user_id() ); + + if ( $onboarding_url->load() ) { + return $onboarding_url->get() ?: ''; } + $onboarding_url->init(); + + $data = $this->partner_referrals_data + ->append_onboarding_token( $data, $onboarding_url->token() ?: '' ); + $url = $is_production ? $this->production_partner_referrals->signup_link( $data ) : $this->sandbox_partner_referrals->signup_link( $data ); $url = add_query_arg( $args, $url ); - $this->cache->set( $environment . '-' . $product, $url, 3 * MONTH_IN_SECONDS ); + $onboarding_url->set( $url ); + $onboarding_url->persist(); return $url; } diff --git a/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php b/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php index 7079b3de6..175333216 100644 --- a/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php +++ b/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php @@ -9,12 +9,15 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Settings; +use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message; +use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\Http\RedirectorInterface; use WooCommerce\PayPalCommerce\Onboarding\State; +use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus; @@ -168,7 +171,7 @@ class SettingsListener { * Listens if the merchant ID should be updated. */ public function listen_for_merchant_id(): void { - if ( ! $this->is_valid_site_request() || $this->state->current_state() === State::STATE_ONBOARDED ) { + if ( ! $this->is_valid_site_request() ) { return; } @@ -177,17 +180,23 @@ class SettingsListener { * phpcs:disable WordPress.Security.NonceVerification.Missing * phpcs:disable WordPress.Security.NonceVerification.Recommended */ - if ( ! isset( $_GET['merchantIdInPayPal'] ) || ! isset( $_GET['merchantId'] ) ) { + if ( ! isset( $_GET['merchantIdInPayPal'] ) || ! isset( $_GET['merchantId'] ) || ! isset( $_GET['ppcpToken'] ) ) { return; } - $merchant_id = sanitize_text_field( wp_unslash( $_GET['merchantIdInPayPal'] ) ); - $merchant_email = sanitize_text_field( wp_unslash( $_GET['merchantId'] ) ); + + $merchant_id = sanitize_text_field( wp_unslash( $_GET['merchantIdInPayPal'] ) ); + $merchant_email = sanitize_text_field( wp_unslash( $_GET['merchantId'] ) ); + $onboarding_token = sanitize_text_field( wp_unslash( $_GET['ppcpToken'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Recommended $this->settings->set( 'merchant_id', $merchant_id ); $this->settings->set( 'merchant_email', $merchant_email ); + if ( ! OnboardingUrl::validate_token_and_delete( $this->signup_link_cache, $onboarding_token, get_current_user_id() ) ) { + $this->onboarding_redirect( false ); + } + $is_sandbox = $this->settings->has( 'sandbox_on' ) && $this->settings->get( 'sandbox_on' ); if ( $is_sandbox ) { $this->settings->set( 'merchant_id_sandbox', $merchant_id ); @@ -203,8 +212,23 @@ class SettingsListener { */ do_action( 'woocommerce_paypal_payments_onboarding_before_redirect' ); - $redirect_url = $this->get_onboarding_redirect_url(); if ( ! $this->settings->has( 'client_id' ) || ! $this->settings->get( 'client_id' ) ) { + $this->onboarding_redirect( false ); + } + + $this->onboarding_redirect(); + } + + /** + * Redirect to the onboarding URL. + * + * @param bool $success Should redirect to the success or error URL. + * @return void + */ + private function onboarding_redirect( bool $success = true ): void { + $redirect_url = $this->get_onboarding_redirect_url(); + + if ( ! $success ) { $redirect_url = add_query_arg( 'ppcp-onboarding-error', '1', $redirect_url ); } diff --git a/psalm-baseline.xml b/psalm-baseline.xml index bddf0058e..42ce967ca 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -685,9 +685,10 @@ listen_for_merchant_id listen_for_vaulting_enabled - + wp_unslash( $_GET['merchantId'] ) wp_unslash( $_GET['merchantIdInPayPal'] ) + wp_unslash( $_GET['ppcpToken'] ) wp_unslash( $_POST['ppcp-nonce'] ) diff --git a/readme.txt b/readme.txt index 7c9d182c4..630d49aa3 100644 --- a/readme.txt +++ b/readme.txt @@ -91,6 +91,8 @@ Follow the steps below to connect the plugin to your PayPal account: * Fix - Advanced Card Processing gateway becomes invisible post-plugin update unless admin pages are accessed once #1432 * Fix - Incompatibility with WooCommerce One Page Checkout (or similar use cases) in Version 2.1.0 #1473 * Fix - Prevent Repetitive Token Migration and Database Overload After 2.1.0 Update #1461 +* Fix - Onboarding from connection page with CSRF parameter manipulates email and merchant id fields #1502 +* Fix - Do not complete non-checkout button orders via webhooks #1513 * Enhancement - Remove feature flag requirement for express cart/checkout block integration #1483 * Enhancement - Add notice when shop currency is unsupported #1433 * Enhancement - Improve ACDC error message when empty fields #1360 diff --git a/tests/PHPUnit/Onboarding/Helper/OnboardingUrlTest.php b/tests/PHPUnit/Onboarding/Helper/OnboardingUrlTest.php new file mode 100644 index 000000000..5dadd2c79 --- /dev/null +++ b/tests/PHPUnit/Onboarding/Helper/OnboardingUrlTest.php @@ -0,0 +1,193 @@ +alias(function($string) { + return hash('md5', $string); + }); + + $this->cache = \Mockery::mock(Cache::class); + $this->onboardingUrl = new OnboardingUrl($this->cache, $this->cache_key_prefix, $this->user_id); + } + + public function test_validate_token_and_delete_valid() + { + // Prepare the data + $cacheData = [ + 'hash_check' => wp_hash(''), + 'secret' => 'test_secret', + 'time' => time(), + 'user_id' => $this->user_id, + 'url' => 'https://example.com' + ]; + + $token = [ + 'k' => $this->cache_key_prefix, + 'u' => $this->user_id, + 'h' => substr(wp_hash(implode( '|', array( + $this->cache_key_prefix, + $cacheData['user_id'], + $cacheData['secret'], + $cacheData['time'], + ))), 0, 32) + ]; + + $onboarding_token = UrlHelper::url_safe_base64_encode(json_encode($token)); + + // Expectations + $this->cache->shouldReceive('has')->once()->andReturn(true); + $this->cache->shouldReceive('get')->once()->andReturn($cacheData); + $this->cache->shouldReceive('delete')->once(); + + $this->assertTrue( + OnboardingUrl::validate_token_and_delete($this->cache, $onboarding_token, $this->user_id) + ); + } + + public function test_load_valid() + { + // Expectations + $this->cache->shouldReceive('has')->once()->andReturn(true); + $this->cache->shouldReceive('get')->once()->andReturn([ + 'hash_check' => wp_hash(''), + 'secret' => 'test_secret', + 'time' => time(), + 'user_id' => $this->user_id, + 'url' => 'https://example.com' + ]); + + $this->assertTrue($this->onboardingUrl->load()); + } + + public function test_load_invalid() + { + // Expectations + $this->cache->shouldReceive('has')->once()->andReturn(false); + + $this->assertFalse($this->onboardingUrl->load()); + } + + public function test_get_not_initialized() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Object not initialized.'); + + $this->onboardingUrl->get(); + } + + public function test_token_not_initialized() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Object not initialized.'); + + $this->onboardingUrl->token(); + } + + public function test_persist_not_initialized() + { + // Expectations + $this->cache->shouldReceive('set')->never(); + + $this->onboardingUrl->persist(); + + $this->assertTrue(true); + } + + public function test_delete() + { + // Expectations + $this->cache->shouldReceive('delete')->once(); + + $this->onboardingUrl->delete(); + + $this->assertTrue(true); + } + + public function test_init() + { + $this->onboardingUrl->init(); + + $token = $this->onboardingUrl->token(); + $this->assertNotEmpty($token); + } + + public function test_set_and_get() + { + $this->onboardingUrl->init(); + $this->onboardingUrl->set('https://example.com'); + + $url = $this->onboardingUrl->get(); + $this->assertEquals('https://example.com', $url); + } + + public function test_persist() + { + $this->onboardingUrl->init(); + $this->onboardingUrl->set('https://example.com'); + + // Expectations + $this->cache->shouldReceive('set')->once(); + + $this->onboardingUrl->persist(); + + $this->assertTrue(true); + } + + public function test_token() + { + $this->onboardingUrl->init(); + $this->onboardingUrl->set('https://example.com'); + + $token = $this->onboardingUrl->token(); + $this->assertNotEmpty($token); + } + + public function test_validate_token_and_delete_invalid() + { + // Prepare the data + $token = [ + 'k' => $this->cache_key_prefix, + 'u' => $this->user_id, + 'h' => 'invalid_hash' + ]; + + $onboarding_token = UrlHelper::url_safe_base64_encode(json_encode($token)); + + // Expectations + $this->cache->shouldReceive('has')->once()->andReturn(true); + $this->cache->shouldReceive('get')->once()->andReturn([ + 'hash_check' => wp_hash(''), + 'secret' => 'test_secret', + 'time' => time(), + 'user_id' => $this->user_id, + 'url' => 'https://example.com' + ]); + $this->cache->shouldReceive('delete')->never(); + + $this->assertFalse( + OnboardingUrl::validate_token_and_delete($this->cache, $onboarding_token, $this->user_id) + ); + } + +}