diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php
index 64efbf819..b456ccb72 100644
--- a/inc/render/class-stripe-checkout-block.php
+++ b/inc/render/class-stripe-checkout-block.php
@@ -15,6 +15,13 @@
* Class Stripe_Checkout_Block
*/
class Stripe_Checkout_Block {
+ /**
+ * Transient prefix for the cached checkout mode of a price.
+ *
+ * @var string
+ */
+ const PRICE_MODE_CACHE_PREFIX = 'otter_stripe_price_mode_';
+
/**
* Stripe API instance.
*
@@ -56,8 +63,8 @@ public function watch_checkout() {
$product_id = isset( $_GET['product_id'] ) ? sanitize_text_field( wp_unslash( $_GET['product_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$price_id = isset( $_GET['price_id'] ) ? sanitize_text_field( wp_unslash( $_GET['price_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $url = isset( $_GET['url'] ) ? sanitize_text_field( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : 'payment'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $url = isset( $_GET['url'] ) ? sanitize_url( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $product_id ) || empty( $price_id ) || empty( $url ) ) {
return sprintf(
@@ -66,12 +73,19 @@ public function watch_checkout() {
);
}
+ if ( ! hash_equals( self::get_checkout_token( $product_id, $price_id ), $token ) ) {
+ return sprintf(
+ '
',
+ __( 'An error occurred! Could not retrieve the product information!', 'otter-blocks' )
+ );
+ }
+
$permalink = add_query_arg(
array(
'stripe_session_id' => '{CHECKOUT_SESSION_ID}',
'product_id' => $product_id,
),
- $url
+ $this->get_return_url( $url )
);
$session = $this->stripe_api->create_request(
@@ -85,7 +99,7 @@ public function watch_checkout() {
'quantity' => 1,
),
),
- 'mode' => $mode,
+ 'mode' => $this->get_mode_for_price( $price_id ),
)
);
@@ -165,17 +179,22 @@ public function render( $attributes ) {
$details_markup .= '' . $currency . $amount . '
';
$details_markup .= '';
- $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment';
+ // A widget area or an FSE template has no permalink of its own, so fall back to the current URL.
+ $return_url = get_permalink();
+
+ if ( ! is_string( $return_url ) || '' === $return_url ) {
+ $return_url = home_url( add_query_arg( array() ) );
+ }
$session_url = add_query_arg(
array(
'action' => 'buy_stripe',
'product_id' => $attributes['product'],
'price_id' => $attributes['price'],
- 'url' => get_permalink(),
- 'mode' => $mode,
+ 'url' => $return_url,
+ 'token' => self::get_checkout_token( $attributes['product'], $attributes['price'] ),
),
- get_permalink()
+ $return_url
);
$button_markup = '' . __( 'Checkout', 'otter-blocks' ) . '';
@@ -188,6 +207,61 @@ public function render( $attributes ) {
);
}
+ /**
+ * Sign a product/price pair so the checkout can verify it was offered by a block.
+ *
+ * @param string $product_id Stripe product ID.
+ * @param string $price_id Stripe price ID.
+ * @return string
+ */
+ public static function get_checkout_token( $product_id, $price_id ) {
+ return hash_hmac( 'sha256', $product_id . '|' . $price_id, wp_salt( 'otter_stripe' ) );
+ }
+
+ /**
+ * Get the URL Stripe returns the buyer to, restricted to this site.
+ *
+ * @param string $url Requested return URL.
+ * @return string
+ */
+ private function get_return_url( $url ) {
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ $home = wp_parse_url( home_url(), PHP_URL_HOST );
+
+ if ( $home !== $host ) {
+ return home_url( '/' );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get the checkout session mode for a price.
+ *
+ * @param string $price_id Stripe price ID.
+ * @return string
+ */
+ private function get_mode_for_price( $price_id ) {
+ $cache_key = self::PRICE_MODE_CACHE_PREFIX . md5( $price_id );
+ $cached = get_transient( $cache_key );
+
+ if ( 'payment' === $cached || 'subscription' === $cached ) {
+ return $cached;
+ }
+
+ $price = $this->stripe_api->create_request( 'price', $price_id );
+
+ if ( is_wp_error( $price ) || ! isset( $price['type'] ) ) {
+ return 'payment';
+ }
+
+ $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment';
+
+ set_transient( $cache_key, $mode, WEEK_IN_SECONDS );
+
+ return $mode;
+ }
+
/**
* Format the error message.
*
diff --git a/src/blocks/test/e2e/blocks/tabs.spec.js b/src/blocks/test/e2e/blocks/tabs.spec.js
index ff339c946..207b10eaa 100644
--- a/src/blocks/test/e2e/blocks/tabs.spec.js
+++ b/src/blocks/test/e2e/blocks/tabs.spec.js
@@ -13,13 +13,13 @@ test.describe( 'Tabs Block', () => {
await admin.createNewPost();
});
- test( 'can be created by typing "/tabs"', async({ editor, page }) => {
+ test( 'can be created by typing "/themeisle-tabs"', async({ editor, page }) => {
// Create a Progress Block with the slash block shortcut.
await insertBlockBySlash({
editor,
page,
- shortcut: '/tabs',
+ shortcut: '/themeisle-tabs',
blockName: 'themeisle-blocks/tabs'
});
});
diff --git a/tests/stripe-http-client-mock.php b/tests/stripe-http-client-mock.php
index 1f170fd90..639692503 100644
--- a/tests/stripe-http-client-mock.php
+++ b/tests/stripe-http-client-mock.php
@@ -11,11 +11,43 @@
class StripeHttpClientMock implements ClientInterface
{
+ /**
+ * Paths requested since the last reset, so tests can assert on API usage.
+ *
+ * @var array
+ */
+ public static $request_paths = array();
+
+ /**
+ * Parameters of the requests made since the last reset, keyed by path.
+ *
+ * @var array
+ */
+ public static $request_params = array();
+
+ public static function reset_request_paths()
+ {
+ self::$request_paths = array();
+ }
+
+ public static function reset_request_params()
+ {
+ self::$request_params = array();
+ }
+
+ public static function get_params_for($path)
+ {
+ return isset(self::$request_params[$path]) ? self::$request_params[$path] : array();
+ }
+
public function request($method, $absUrl, $headers, $params, $hasFile)
{
$urlParts = parse_url($absUrl);
$path = $urlParts['path'];
+ self::$request_paths[] = $path;
+ self::$request_params[$path] = $params;
+
if ($path === '/v1/products') {
return array($this->mockProductsList(), 200, null);
}
@@ -101,6 +133,7 @@ private function mockSingleProduct()
'price' => 1200,
'currency' => 'USD',
'active' => true,
+ 'images' => [],
'object' => 'product'
]
);
diff --git a/tests/test-stripe-checkout-block.php b/tests/test-stripe-checkout-block.php
new file mode 100644
index 000000000..36f7efea4
--- /dev/null
+++ b/tests/test-stripe-checkout-block.php
@@ -0,0 +1,260 @@
+previous_api_key = get_option( 'themeisle_stripe_api_key' );
+
+ update_option( 'themeisle_stripe_api_key', 'sk_test' );
+ \Stripe\ApiRequestor::setHttpClient( new StripeHttpClientMock() );
+
+ $this->block = new Stripe_Checkout_Block();
+
+ $this->post_id = $this->factory()->post->create(
+ array(
+ 'post_status' => 'publish',
+ 'post_content' => '',
+ )
+ );
+
+ // wp_safe_redirect() would end the request, so turn the redirect into an exception.
+ add_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) );
+ }
+
+ /**
+ * Tear down the test.
+ */
+ public function tear_down() {
+ remove_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) );
+ unset( $_GET['action'], $_GET['product_id'], $_GET['price_id'], $_GET['url'], $_GET['token'] );
+
+ delete_transient( Stripe_Checkout_Block::PRICE_MODE_CACHE_PREFIX . md5( 'price_1' ) );
+
+ if ( false === $this->previous_api_key ) {
+ delete_option( 'themeisle_stripe_api_key' );
+ } else {
+ update_option( 'themeisle_stripe_api_key', $this->previous_api_key );
+ }
+
+ // The library falls back to this client when none is set, so it is the default to restore.
+ \Stripe\ApiRequestor::setHttpClient( \Stripe\HttpClient\CurlClient::instance() );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Turn a redirect into an exception carrying the location.
+ *
+ * @param string $location Redirect location.
+ * @throws Exception Always.
+ */
+ public function throw_on_redirect( $location ) {
+ throw new Exception( $location );
+ }
+
+ /**
+ * Build the request parameters a rendered block would produce, with overrides applied.
+ *
+ * @param array $overrides Parameters to override.
+ * @return array
+ */
+ private function checkout_params( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'product_id' => 'prod_1',
+ 'price_id' => 'price_1',
+ 'url' => get_permalink( $this->post_id ),
+ 'token' => Stripe_Checkout_Block::get_checkout_token( 'prod_1', 'price_1' ),
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Run the checkout watcher with the given request parameters.
+ *
+ * @param array $params Request parameters.
+ * @return string Redirect location, or '' when no session was created.
+ */
+ private function run_checkout( $params ) {
+ $_GET = array_merge( array( 'action' => 'buy_stripe' ), $params );
+
+ try {
+ $this->block->watch_checkout();
+ } catch ( Exception $e ) {
+ return $e->getMessage();
+ }
+
+ return '';
+ }
+
+ /**
+ * A pair signed by a rendered block creates a session.
+ */
+ public function test_signed_pair_creates_session() {
+ $location = $this->run_checkout( $this->checkout_params() );
+
+ $this->assertStringContainsString( 'checkout.stripe.com', $location );
+ }
+
+ /**
+ * The token emitted by the rendered block is accepted as-is.
+ */
+ public function test_token_from_rendered_block_is_accepted() {
+ $markup = $this->block->render(
+ array(
+ 'product' => 'prod_1',
+ 'price' => 'price_1',
+ )
+ );
+
+ preg_match( '/token=([a-f0-9]+)/', html_entity_decode( $markup ), $matches );
+
+ $this->assertNotEmpty( $matches, 'The buy link should carry a token.' );
+
+ $location = $this->run_checkout( $this->checkout_params( array( 'token' => $matches[1] ) ) );
+
+ $this->assertStringContainsString( 'checkout.stripe.com', $location );
+ }
+
+ /**
+ * A block outside post content still works, because the token does not depend on placement.
+ */
+ public function test_widget_placement_is_accepted() {
+ $widget_only_id = $this->factory()->post->create(
+ array(
+ 'post_status' => 'publish',
+ 'post_content' => 'The checkout block lives in a widget area, not here.',
+ )
+ );
+
+ $location = $this->run_checkout( $this->checkout_params( array( 'url' => get_permalink( $widget_only_id ) ) ) );
+
+ $this->assertStringContainsString( 'checkout.stripe.com', $location );
+ }
+
+ /**
+ * A non-singular URL that resolves to no post is still a valid return URL.
+ */
+ public function test_archive_url_is_accepted() {
+ $location = $this->run_checkout( $this->checkout_params( array( 'url' => home_url( '/2026/08/' ) ) ) );
+
+ $this->assertStringContainsString( 'checkout.stripe.com', $location );
+ }
+
+ /**
+ * A request without a token is rejected.
+ */
+ public function test_missing_token_is_rejected() {
+ $location = $this->run_checkout( $this->checkout_params( array( 'token' => '' ) ) );
+
+ $this->assertSame( '', $location );
+ }
+
+ /**
+ * A price swapped in while keeping the token of another pair is rejected.
+ */
+ public function test_substituted_price_is_rejected() {
+ $location = $this->run_checkout( $this->checkout_params( array( 'price_id' => 'price_cheap' ) ) );
+
+ $this->assertSame( '', $location );
+ }
+
+ /**
+ * A product swapped in while keeping the token of another pair is rejected.
+ */
+ public function test_substituted_product_is_rejected() {
+ $location = $this->run_checkout( $this->checkout_params( array( 'product_id' => 'prod_2' ) ) );
+
+ $this->assertSame( '', $location );
+ }
+
+ /**
+ * A token that was not derived from the site salt is rejected.
+ */
+ public function test_forged_token_is_rejected() {
+ $forged = hash_hmac( 'sha256', 'prod_1|price_cheap', 'guessed-salt' );
+
+ $location = $this->run_checkout(
+ $this->checkout_params(
+ array(
+ 'price_id' => 'price_cheap',
+ 'token' => $forged,
+ )
+ )
+ );
+
+ $this->assertSame( '', $location );
+ }
+
+ /**
+ * An off-site return URL is replaced with this site's home URL.
+ */
+ public function test_offsite_return_url_is_replaced() {
+ StripeHttpClientMock::reset_request_params();
+
+ $this->run_checkout( $this->checkout_params( array( 'url' => 'https://evil.example.net/collect' ) ) );
+
+ $params = StripeHttpClientMock::get_params_for( '/v1/checkout/sessions' );
+
+ $this->assertNotEmpty( $params );
+ $this->assertStringStartsWith( home_url(), $params['success_url'] );
+ $this->assertStringStartsWith( home_url(), $params['cancel_url'] );
+ }
+
+ /**
+ * The checkout mode is derived from the price and then served from cache.
+ */
+ public function test_price_mode_is_cached() {
+ $params = $this->checkout_params();
+
+ StripeHttpClientMock::reset_request_paths();
+ $this->run_checkout( $params );
+
+ $this->assertContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths );
+ $this->assertSame( 'payment', get_transient( Stripe_Checkout_Block::PRICE_MODE_CACHE_PREFIX . md5( 'price_1' ) ) );
+
+ StripeHttpClientMock::reset_request_paths();
+ $location = $this->run_checkout( $params );
+
+ $this->assertNotContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths );
+ $this->assertStringContainsString( 'checkout.stripe.com', $location );
+ }
+}