Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
snippet running under file-based execution.
* Fixed the snippet editor reporting a save as failed and undelivered when no response was received, which it cannot
determine.
* Fixed safe mode failing on sites running a plugin that builds URLs while WordPress is identifying the current user.

## [3.10.2] (2026-09-01)

Expand Down
31 changes: 30 additions & 1 deletion src/php/Integration/Evaluate_Functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,28 @@ public function is_safe_mode_query_var_set(): bool {
* @return bool
*/
public function is_safe_mode_requested(): bool {
return $this->is_safe_mode_query_var_set() && code_snippets()->current_user_can();
static $resolving = false;

if ( ! $this->is_safe_mode_query_var_set() ) {
return false;
}

// Asking for a capability makes WordPress resolve the current user,
// which runs third-party callbacks on determine_current_user. One that
// builds a URL arrives back here before that resolution has finished,
// and answering it again would restart it, recursing until the request
// ran out of memory. No user is known yet at that point, so it is no.
if ( $resolving ) {
return false;
}

$resolving = true;

try {
return code_snippets()->current_user_can();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need to actually check the user here? I feel like we can just pass on the var whenever it's set, and then only check the current user when evaluating snippets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that it works, and it is simpler. I tried it: with the capability check taken out of this callback, an anonymous request carrying the query var comes back 200 and the recursion is gone without needing the guard at all. It also drops a current_user_can() call that currently runs on every home_url() in the page, which is worth having.

One consequence to weigh first. home_url() is a base that callers append to, so adding the query var to it corrupts anything built by concatenation. Passing the var on unconditionally does that for every visitor:

http://example.test?snippets-safe-mode=1%2Findex.php&rest_route=/

That is the REST URL on plain permalinks, and it is wrong.

Worth saying that this is not caused by either version — it already happens for a user who does hold the capability, on core-beta as it stands:

http://example.test?snippets-safe-mode=1%2Findex.php&rest_route=/code-snippets/v1/snippets

So safe mode breaks REST URLs for administrators today on plain permalinks, which matters given the manage screen needs the REST API and safe mode is the route in when something is broken. I'll raise that separately. Taking the capability check out here widens it from administrators to everyone, which is the only reason I'd hesitate.

A third option, if you like it: take the simplification and stop filtering home_url as well, keeping only admin_url. Safe mode navigation is an admin concern, and rest_url() derives from home_url(), so that removes the malformed URLs rather than extending them. No more code than your suggestion.

Happy to go whichever way you prefer — it's your integration.

} finally {
$resolving = false;
}
}

/**
Expand All @@ -81,6 +102,14 @@ public function is_safe_mode_requested(): bool {
public function add_safe_mode_query_var( $url ): string {
$url = is_string( $url ) ? $url : '';

// A URL built before WordPress has settled on a user belongs to that
// resolution rather than to anything a visitor follows, so it is left
// alone. Safe mode links are generated while a page renders, which is
// long after this point.
if ( ! did_action( 'set_current_user' ) ) {
return $url;
}

return $this->is_safe_mode_requested() ?
add_query_arg( 'snippets-safe-mode', true, $url ) :
$url;
Expand Down
152 changes: 152 additions & 0 deletions tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@
*/
class Evaluate_Functions_Safe_Mode_Test extends UnitTestCase {

/**
* Calls to the current-user filter after which it stops building URLs.
*
* Past anything a correct implementation produces, but low enough that a
* regression fails on the count rather than exhausting the memory limit and
* taking the whole test run down with it.
*/
private const RESOLUTION_LIMIT = 5;

/**
* How many times the current-user filter was called during a test.
*
* @var int
*/
private int $resolution_count = 0;

/**
* The last URL built while the current user was being resolved.
*
* @var string|null
*/
private ?string $resolved_url = null;

/**
* User the current-user filter resolves to.
*
* @var int|false
*/
private $user_to_resolve = false;

/**
* Set up before each test.
*
Expand All @@ -20,6 +50,9 @@ class Evaluate_Functions_Safe_Mode_Test extends UnitTestCase {
public function set_up() {
parent::set_up();
unset( $_REQUEST['snippets-safe-mode'] );
$this->resolution_count = 0;
$this->resolved_url = null;
$this->user_to_resolve = false;
}

/**
Expand All @@ -29,9 +62,42 @@ public function set_up() {
*/
public function tear_down() {
unset( $_REQUEST['snippets-safe-mode'] );
remove_filter( 'determine_current_user', [ $this, 'resolve_user_through_a_url' ], 15 );
parent::tear_down();
}

/**
* Stand in for a plugin that builds a URL while the current user is being resolved.
*
* WooCommerce does this: its REST authentication runs on determine_current_user
* and calls home_url() to work out whether the request is for one of its routes.
*
* @param int|false $user_id Resolved user, from an unknown earlier callback.
*
* @return int|false
*/
public function resolve_user_through_a_url( $user_id ) {
++$this->resolution_count;

if ( $this->resolution_count < self::RESOLUTION_LIMIT ) {
$this->resolved_url = home_url( '/' );
}

return false === $this->user_to_resolve ? $user_id : $this->user_to_resolve;
}

/**
* Force WordPress to resolve the current user again on the next capability check.
*
* @return void
*/
private function require_user_resolution(): void {
global $current_user;

// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- emptying the cached user is the only way to make WordPress resolve one again, which is the whole point of these tests.
$current_user = null;
}

/**
* The query var check must not touch capabilities.
*
Expand Down Expand Up @@ -88,4 +154,90 @@ public function test_execution_callback_tolerates_a_null_from_an_earlier_callbac
$this->assertFalse( $evaluate->disable_snippet_execution( null ) );
$this->assertTrue( $evaluate->disable_snippet_execution( true ) );
}

/**
* Resolving the current user must not re-enter the capability check.
*
* A plugin that builds a URL on determine_current_user reaches the URL
* filter before WordPress knows who the user is. Checking the capability
* again there restarts the resolution that is already running, which
* recurses until the request exhausts its memory limit. Any visitor can
* trigger it by putting the query var on a front-end URL.
*
* @return void
*/
public function test_url_filter_does_not_restart_user_resolution(): void {
$_REQUEST['snippets-safe-mode'] = '1';

$evaluate = new Evaluate_Functions( code_snippets()->db );

add_filter( 'determine_current_user', [ $this, 'resolve_user_through_a_url' ], 15 );
$this->require_user_resolution();

$evaluate->disable_snippet_execution( true );

$this->assertSame(
1,
$this->resolution_count,
'the current user should be resolved once per request, not once per URL built while resolving'
);
}

/**
* A URL built during resolution comes back untouched.
*
* The user the request will turn out to belong to is not known while that
* URL is being built, so safe mode cannot yet be part of it, even when the
* user being resolved does hold the capability.
*
* @return void
*/
public function test_url_built_during_user_resolution_is_unchanged(): void {
$_REQUEST['snippets-safe-mode'] = '1';
$this->user_to_resolve = $this->factory()->user->create( [ 'role' => 'administrator' ] );

$evaluate = new Evaluate_Functions( code_snippets()->db );

add_filter( 'determine_current_user', [ $this, 'resolve_user_through_a_url' ], 15 );
$this->require_user_resolution();

$evaluate->disable_snippet_execution( true );

$this->assertIsString( $this->resolved_url );
$this->assertStringNotContainsString( 'snippets-safe-mode', $this->resolved_url );
}

/**
* Safe mode still reaches the URLs an administrator follows.
*
* @return void
*/
public function test_query_var_is_added_for_a_user_with_the_capability(): void {
$_REQUEST['snippets-safe-mode'] = '1';
wp_set_current_user( $this->factory()->user->create( [ 'role' => 'administrator' ] ) );

$evaluate = new Evaluate_Functions( code_snippets()->db );

$this->assertStringContainsString(
'snippets-safe-mode=1',
$evaluate->add_safe_mode_query_var( 'https://example.org/wp-admin/' )
);
}

/**
* Safe mode stays out of the URLs a visitor without the capability follows.
*
* @return void
*/
public function test_query_var_is_not_added_for_a_user_without_the_capability(): void {
$_REQUEST['snippets-safe-mode'] = '1';
wp_set_current_user( 0 );

$evaluate = new Evaluate_Functions( code_snippets()->db );

$this->assertSame(
'https://example.org/',
$evaluate->add_safe_mode_query_var( 'https://example.org/' )
);
}
}
Loading