Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create pending renewal order (improve safety/error handling) #768

Merged
merged 9 commits into from
Jan 23, 2025
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Fix - Safeguards added to the Subscriptions Totals template used in the My Account area, to guard against fatal errors that could arise in unusual conditions.
* Fix - Correctly load product names with HTML on the cart and checkout shipping rates.
* Fix - Improve our admin notice handling to ensure notices are displayed to the intended admin user.
* Fix - Improve protections around the pending renewal order-creation process, to prevent uncaught exceptions and other errors from breaking the subscription editor.

= 7.9.0 - 2025-01-10 =
* Add - Compatibility with WooCommerce's new preview email feature introduced in 9.6.
Expand Down
74 changes: 68 additions & 6 deletions includes/admin/class-wcs-admin-meta-boxes.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,21 +247,65 @@ public static function process_renewal_action_request( $subscription ) {
/**
* Handles the action request to create a pending renewal order.
*
* @param array $subscription
* @since 1.0.0 - Migrated from WooCommerce Subscriptions v2.0
* @param WC_Subscription $subscription
*/
public static function create_pending_renewal_action_request( $subscription ) {
$subscription->add_order_note( __( 'Create pending renewal order requested by admin action.', 'woocommerce-subscriptions' ), false, true );
$subscription->update_status( 'on-hold' );

try {
$subscription->update_status( 'on-hold' );
} catch ( Exception $e ) {
wcs_add_admin_notice(
__( 'Pending renewal order was not created, as it was not possible to update the subscription status.', 'woocommerce-subscriptions' ),
'error'
);
return;
}

$renewal_order = wcs_create_renewal_order( $subscription );

if ( is_wp_error( $renewal_order ) ) {
self::notify(
$subscription,
'error',
esc_html__( 'Creation of the pending renewal order failed.', 'woocommerce-subscriptions' )
. ' ' . $renewal_order->get_error_message()
);

return;
}

if ( ! $subscription->is_manual() ) {
$renewal_url = $renewal_order->get_edit_order_url();

try {
// We need to pass the payment gateway instance to be compatible with WC < 3.0, only WC 3.0+ supports passing the string name.
$renewal_order->set_payment_method( wc_get_payment_gateway_by_order( $subscription ) );

$renewal_order->set_payment_method( wc_get_payment_gateway_by_order( $subscription ) ); // We need to pass the payment gateway instance to be compatible with WC < 3.0, only WC 3.0+ supports passing the string name
if ( is_callable( array( $renewal_order, 'save' ) ) ) { // WC 3.0+
$renewal_order->save();
}

if ( is_callable( array( $renewal_order, 'save' ) ) ) { // WC 3.0+
$renewal_order->save();
wcs_add_admin_notice(
sprintf(
/* Translators: %1$s opening link tag, %2$s closing link tag. */
esc_html__( 'A pending %1$srenewal order%2$s was successfully created!', 'woocommerce-subscriptions' ),
Copy link
Contributor

@mattallan mattallan Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been pondering whether to send this feedback as it feels very minor... 😅

While testing these changes, I noticed that the wcs_create_renewal_order() function already adds a similar order note to this one, and so on success, you have two very similar order notes added which feels unnecessary:
N5dQi5.png

The Create pending renewal order requested by admin action. order note was purposefully added to the start of the function in https://github.com/woocommerce/woocommerce-subscriptions/pull/3462 and I believe it served two purposes:

  1. For debugging purposes, having it at the very start allows us to know whether an issue was coming from an admin requested the action before any uncaught exceptions or php shutdowns happened (worst case I'm talking someone calling exit; 🙈 )
  2. It separates the last renewal order notes from the start of the new renewal order notes, for example:
trunk this branch
image image

Here's what I'm thinking

  1. Add back the order note at the start signalling the start of an admin requested action (consistent with our other subscription actions like "Process renewal"
  2. Keep this admin notice with the link to the renewal order, but don't include the order note.

Curious to hear your thoughts on this!

The other idea I had was to remove the initial order note at the start and instead pass "Create pending renewal order requested by admin action." as the $note param when calling $subscription->update_status( 'on-hold', $note ); 🤔 but this also introduces inconsistencies with the similar "Process Renewal" action.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Add back the order note at the start signalling the start of an admin requested action (consistent with our other subscription actions like "Process renewal"
  2. Keep this admin notice with the link to the renewal order, but don't include the order note.

Agreed, except (and here I'm probably missing a trick) I don't think the original Order #XYZ created to record renewal ever disappeared. At least, I can't seem to replicate what I see in your screenshot from this branch. Perhaps we're each approaching this in a slightly different way? 🤔

Nonetheless, I agree with the concept, and committed f9114d0, which should give us the admin notice (top of screen) alongside the existing order note:

Admin notice, and order note, confirming a renewal was created.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been pondering whether to send this feedback as it feels very minor...

Btw, very glad to get notes like this. I think this is how we remove some of the papercuts from the UX (or, in this case, it's how we avoid adding new papercuts, so to speak).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least, I can't seem to replicate what I see in your screenshot from this branch. Perhaps we're each approaching this in a slightly different way?

Oh sorry I probably should've shared how I was testing this 😅

  1. Purchase a subscription product
  2. Process a regular renewal by either running the pending scheduled action for this subscription or using the "Process Renewal" subscription action.
  3. Process the "Create pending renewal order" action.

I'm not sure if this helps but here's a longer screenshot:

trunk this branch
image image

With the "requested by admin action" note being added at the start of the process, it acts as a separator but also adds context for why this renewal was created.

For that reason, as well as keeping this action consistent with the "Process Renewal" action, I think we should add back this order note which was removed.

Copy link
Member Author

@barryhughes barryhughes Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Edit: ignore this comment, I see what you mean now.)

With the "requested by admin action" note being added at the start of the process, it acts as a separator but also adds context for why this renewal was created.

Hmm. I wasn't missing this before, and on trying to replicate once more I do still get the requested by admin action note:

Order note history, after processing a renewal for a subscription

Or perhaps besides a subtle difference in testing, a further commit addressed this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☝🏼 Ignore!

'<a href="' . esc_url( $renewal_url ) . '">',
'</a>'
),
'success'
);
} catch ( WC_Data_Exception $e ) {
self::notify(
$subscription,
'error',
sprintf(
/* Translators: %1$s opening link tag, %2$s closing link tag. */
esc_html__( 'A %1$spending renewal order%2$s was successfully created, but there was a problem setting the payment method. Please review the order.', 'woocommerce-subscriptions' ),
'<a href="' . esc_url( $renewal_url ) . '">',
'</a>'
)
);
}
}
}
Expand Down Expand Up @@ -722,4 +766,22 @@ private static function reorder_subscription_line_items_meta_box() {
$items_meta_box['args']
);
}

/**
* Notifies the user of an operational success or failure, and records a matching order note.
*
* In essence, it can be convenient to generate both an admin notice (to give the user some clear and
* obvious feedback) and record the same as an order note (the admin notice could be missed, and is
* auto-dismissed after the first view).
*
* @param WC_Subscription $subscription The subscription we are working with.
* @param string $type Message type: 'success' or 'error.
* @param string $message Message text, which will be used both for an admin notice and for the order note.
*
* @return void
*/
private static function notify( WC_Subscription $subscription, $type, $message ) {
$subscription->add_order_note( $message, false, true );
wcs_add_admin_notice( $message, $type );
}
}
2 changes: 1 addition & 1 deletion includes/admin/wcs-admin-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function wcs_display_admin_notices( $clear = true ) {
continue;
}

$notice_output[] = esc_html( $notice['message'] );
$notice_output[] = $notice['message'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does removing this esc_html mean we should be escaping every other use of wcs_add_admin_notice() within Subscriptions and the other extensions that use it (WooPayments, Gifting Subscriptions).

I could be wrong by my understanding is that just having wp_kses_post is only okay if we trust the content, but given these messages are typically passed through __(), wp_kses_post() won't be enough as it can still allow inline javascript through :(


I'm sorry for putting you on this rollercoaster! I didn't even consider this escaping links issue when suggesting putting links in the admin notices 😭

What are your thoughts given the above?

I'm thinking we roll back the idea of including a link in the notice, but then that means we don't have a need for the new notify() function you added because I'm assuming we'll still want to add links to the order notes? Oh man 🙈

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who doesn't enjoy rollercoasters? 🎢 ...

I think the links are useful—the user experience feels better—so it would be great to keep them if we can do so safely. Drawing some amount of inspiration from Woo Core's HtmlSanitizer, what if continue to use Kses, but with a tighter set of rules than we get from wp_kses_post()?

$allow_links = array(
	'a' => array(
		'href' => true,
	),
);

wp_kses( $html, $allow_links );

We could expand this over time if, for instance, we wished to allow <em>, <strong>, etc. However...

I'm thinking we roll back the idea of including a link in the notice, but then that means we don't have a need for the new notify() function you added because I'm assuming we'll still want to add links to the order notes?

I'm not tied to the new notify() method, we can drop it if it turns out to be problematic and we can also drop the link from the admin notice if that feels better/safer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would still allow bad URLs like this wouldn't it?

<a href="javascript:alert('XSS')">Click here</a>

Maybe we can go a step further and manually escape all hrefs for example?

$allow_links = [
    'a' => [
        'href' => true,
    ],
];

// Escape href values
$sanitized_message = preg_replace_callback(
    '/href="([^"]*)"/i',
    function ( $matches ) {
        return 'href="' . esc_url( $matches[1] ) . '"';
    },
    $notice_message
);

wp_kses( $sanitized_message, $allow_links );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would still allow bad URLs like this wouldn't it?

<a href="javascript:alert('XSS')">Click here</a>

As with so many things in WordPress, this isn't fixed in stone, but assuming defaults that wouldn't be possible (because javascript is not one of the allowed protocols). We'd get back some HTML looking like this:

<a href="alert('XSS')">Click here</a>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL! Thanks @barryhughes for sharing! I guess if some malicious actor has access to filter 'kses_allowed_protocols' then we're in deep trouble, unless another plugin adds it unknowing that is poses security risks.

In any case, given the above, I think I'm fine to stick with either wp_kses( $sanitized_message, $allow_links ); or leave it with wp_kses_post() since my concerns were mainly injecting JS/some other script.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Massively overthinking this no-doubt, but wcs_add_admin_notice() could be updated to support an array of links. Using modern syntax only to keep the example concise:

wcs_add_admin_notice( 
	'A new order was created.',
	links: [
		__( 'Review new order', 'txtdomain' ) => get_admin_url( 'foo bar baz' ),
		__( 'Cancel new order', 'txtdomain' ) => get_admin_url( 'bar baz foo' ),
		__( 'Give us 5 stars', 'txtdomain' )  => get_admin_url( 'baz foo bar' ),
	]
);

The output might then look a little like this:

Mock of admin notice with supporting links

Escaping being done as late as possible, from within wcs_display_admin_notices().

To be clear, though, I'm just jamming on possibilities rather than pushing this as my preferred choice. I think it's fairly reasonable, and especially secure, but I also think using Kses is fine, or we could do as you suggest with a regex, or we could just drop the links from these messages (if anything, that's my least prefered option, because I think we lose something if we do, but it's definitely not a deal breaker).

unset( $notices[ $index ] );
}

Expand Down
12 changes: 11 additions & 1 deletion includes/wcs-renewal-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,17 @@ function wcs_create_renewal_order( $subscription ) {

WCS_Related_Order_Store::instance()->add_relation( $renewal_order, $subscription, 'renewal' );

return apply_filters( 'wcs_renewal_order_created', $renewal_order, $subscription );
/**
* Provides an opportunity to monitor, interact with and replace renewal orders when they
* are first created.
*
* @param WC_Order $renewal_order The renewal order.
* @param WC_Subscription $subscription The subscription the renewal is related to.
*/
$filtered_renewal_order = apply_filters( 'wcs_renewal_order_created', $renewal_order, $subscription );

// It is possible that a filter function will replace the renewal order with something else entirely.
return $filtered_renewal_order instanceof WC_Order ? $filtered_renewal_order : $renewal_order;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to chime in to say I ❤️ this change. I was looking at #757 and I think that error wouldn't have occurred with this change.

I wonder if it would be worth to report these kind of failures somewhere. I'm thinking that if there are 2 or 3+ hooks which use this filter correctly, the changes they make would be overridden by 1 poorly written piece of code that returns a bool or null etc.

We don't have a good mechanism for doing this kind of thing but I'm thinking something like a WC log entry or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd definitely be up for developing this concept a little further (and agree there is lots of value in logging when things go wrong).

}

/**
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test-wcs-admin-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@ public function test_wcs_admin_notice_queue_clearance() {
wcs_add_admin_notice( $message_text, 'error' );

$this->assertStringContainsString(
esc_html( $message_text ),
$message_text,
$this->capture_wcs_admin_notice_text( false ),
'The admin notice is displayed as expected.'
);

$this->assertStringContainsString(
esc_html( $message_text ),
$message_text,
$this->capture_wcs_admin_notice_text(),
'The admin notice is displayed a second time, because it was not cleared last time.'
);
Expand Down
Loading