Add security to the onboarding return URL.

This commit is contained in:
Pedro Silva 2023-07-07 18:44:53 +01:00
parent 5a0b0b41a9
commit 96eae6c690
No known key found for this signature in database
GPG key ID: E2EE20C0669D24B3
8 changed files with 630 additions and 9 deletions

View file

@ -0,0 +1,18 @@
<?php
/**
* The modules Runtime Exception.
*
* @package WooCommerce\PayPalCommerce\Onboarding\Exception
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Onboarding\Exception;
/**
* Class RuntimeException
*/
class RuntimeException extends \RuntimeException {
}

View file

@ -0,0 +1,318 @@
<?php
/**
* Manages an Onboarding Url / Token to preserve /v2/customer/partner-referrals action_url integrity.
*
* @package WooCommerce\PayPalCommerce\Onboarding\Helper
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Onboarding\Helper;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Onboarding\Exception\RuntimeException;
/**
* Class OnboardingUrl
*/
class OnboardingUrl {
/**
* The user ID to associate with the cache key
*
* @var int
*/
private $user_id;
/**
* The cryptographically secure secret
*
* @var ?string
*/
private $secret = null;
/**
* Unix Timestamp when token was generated
*
* @var ?int
*/
private $time = null;
/**
* The "action_url" from /v2/customer/partner-referrals
*
* @var ?string
*/
private $url = null;
/**
* The cache object
*
* @var Cache
*/
private $cache;
/**
* The prefix for the cache key
*
* @var string
*/
private $cache_key_prefix;
/**
* The TTL for the cache.
*
* @var int
*/
private $cache_ttl = 3 * MONTH_IN_SECONDS;
/**
* The constructor
*
* @param Cache $cache The cache object to store the URL.
* @param string $cache_key_prefix The prefix for the cache entry.
* @param int $user_id User ID to associate the link with.
*/
public function __construct(
Cache $cache,
string $cache_key_prefix,
int $user_id
) {
$this->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 ) );
}
}

View file

@ -0,0 +1,42 @@
<?php
/**
* Provides Helper functions for URL handling
*
* @package WooCommerce\PayPalCommerce\Onboarding\Helper
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Onboarding\Helper;
/**
* Class OnboardingUrl
*/
class UrlHelper {
/**
* Does a base64 encode of a string safe to be used on a URL
*
* @param string $string The string to be encoded.
* @return string
*/
public static function url_safe_base64_encode( string $string ): string {
//phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$encoded_string = base64_encode( $string );
$url_safe_string = str_replace( array( '+', '/' ), array( '-', '_' ), $encoded_string );
return rtrim( $url_safe_string, '=' );
}
/**
* Does a base64 decode of a string URL safe string
*
* @param string $url_safe_string The string to be decoded.
* @return false|string
*/
public static function url_safe_base64_decode( string $url_safe_string ) {
$padded_string = str_pad( $url_safe_string, strlen( $url_safe_string ) % 4, '=', STR_PAD_RIGHT );
$encoded_string = str_replace( array( '-', '_' ), array( '+', '/' ), $padded_string );
//phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
return base64_decode( $encoded_string );
}
}

View file

@ -13,6 +13,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
/**
@ -96,14 +97,24 @@ class OnboardingRenderer {
$environment = $is_production ? 'production' : 'sandbox';
$product = 'PPCP' === $data['products'][0] ? 'ppcp' : 'express_checkout';
if ( $this->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;
}