Skip to content

Any $pages->save() revokes the implicit page-view grant for the rest of the request #2348

Description

@apdekker93

Short description of the issue

Roles::loaded() grants page-view to every role in memory and never stores it, so in a stock install field_permissions is empty. That grant does not survive Page::uncache(): reloading the field finds nothing in the database, and Roles::loaded() does not run again because it only fires when the page is fetched through $roles, not when a field is lazily reloaded.

Since $pages->save() calls uncacheAll(), any save leaves a non-superuser without page-view for the rest of the request. This breaks the core password reset, which returns a 500 and names the wrong module.

Expected behavior

$user->hasPermission('page-view') keeps returning true after an unrelated $pages->save() in the same request.

Actual behavior

It returns false, and the role's permissions PageArray is empty.

Steps to reproduce the issue

On a clean 3.0.255 install with the blank profile and no third-party modules:

$guest = $users->get('guest');
$guest->hasPermission('page-view');            // true
$guest->roles->first()->permissions->count();  // 1

$pages->uncacheAll();                          // or any $pages->save()

$guest->hasPermission('page-view');            // false
$guest->roles->first()->permissions->count();  // 0

Add a row to field_permissions for that role and permission, and the problem disappears: the value then reloads from the database and both calls keep returning true.

Why the grant is runtime-only

wire/core/Roles.php:110-119:

protected function loaded(Page $page) {
	$hasPageView = false;
	foreach($page->permissions as $permission) {
		if($permission->name === 'page-view') $hasPageView = true;
		if($hasPageView) break;
	}
	if(!$hasPageView) {
		$pageView = $this->wire()->permissions->get('page-view');
		$page->permissions->add($pageView);
	}
}

field_permissions is empty in site-blank/install/install.sql, so on a fresh install every role gets page-view this way and nothing else.

wire/core/Page.php:3921-3925 then discards it:

foreach($fieldgroup as $field) {
	$value = parent::get($field->name);
	if(!is_object($value)) continue;
	parent::set($field->name, null);
	unset($this->wakeupNameQueue[$field->name]);
}

wire/core/PagesLoaderCache.php:203-208 preserves the current user's own page but not the role pages it references:

foreach($this->pageIdCache as $id => $page) {
	if($id == $user->id || ($language && $language->id == $id)) continue;
	if($page->numChildren) continue;
	if(empty($options['shallow'])) $page->uncache();

How it surfaces in the core password reset

  1. A logged-out visitor completes a reset. ProcessForgotPassword.module:624 saves the new password, so Pages::save() calls uncacheAll().
  2. Line 635 redirects. Session::redirect() fetches ProcessPageView at Session.php:1387, because wire('process') is ProcessLogin rather than ProcessPageView.
  3. ModulesLoader::hasPermission() calls $user->hasPermission('page-view'), which now returns false, and throws.
  4. wire/core/admin.php:183 catches that and calls $modules->get("ProcessLogin") at line 192 to render the login form. That call fails for the same reason and is uncaught.

The visitor gets a 500. The password has already been changed by then, which makes the failure look unrelated to the save.

The reported exception is the second one, so it points at the wrong module:

WirePermissionException: You do not have permission to execute this module - ProcessLogin (page=23)
in wire/core/Modules.php:599

Logging the first one through $config->statusFiles('failed', ...) shows what actually happened:

[Permission error from ProcessLogin] WirePermissionException:
You do not have permission to execute this module - ProcessPageView (page=23)
user=guest (id=40) roles=guest page-view=no

#0 wire/core/Modules.php(453): Modules->getModule()
#1 wire/core/Session.php(1387): Modules->get()
#2 wire/core/Wire.php(416): Session->___redirect()
#5 ProcessForgotPassword.module(730): Wire->__call()
#6 ProcessForgotPassword.module(635): ProcessForgotPassword->deleteResetAndRedirect()
#7 ProcessForgotPassword.module(470): ProcessForgotPassword->step4_completeReset()

Note roles=guest and page-view=no at the same time: the role is still attached, only its permissions are gone.

This stays invisible in most situations because three things have to coincide in one request: the user is not a superuser, a page is saved, and a permission check follows. A password reset by a logged-out visitor is one of the few core flows that does all three.

Suggested fix

Either re-apply the Roles::loaded() grant when a Role's permissions field is reloaded, or preserve the current user's roles in uncacheAll() alongside the user page itself.

Two workarounds, both verified:

Store the grant, so there is something to reload:

foreach($roles as $role) {
	$role->of(false);
	$role->trackChange('permissions'); // the value is unchanged in memory, so mark it
	$role->save();
}

Note that saving the role from the admin does not do this: the form shows page-view already checked, so no change is detected and nothing is written.

Or re-fetch the roles after each save:

$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
	$user = $event->wire()->user;
	if(!$user || $user->isSuperuser()) return;
	$roles = $event->wire()->roles;
	$fresh = $event->wire(new PageArray());
	foreach($user->getUnformatted('roles') as $role) {
		$reloaded = $roles->get((int) $role->id);
		if($reloaded && $reloaded->id) $fresh->add($reloaded);
	}
	if($fresh->count()) $user->setQuietly('roles', $fresh);
});

Setup/Environment

  • ProcessWire 3.0.255. PagesLoaderCache::uncacheAll() is identical in 3.0.259, so this is not fixed there.
  • Reproduced on a clean 3.0.255 install with the site-blank profile and a fresh database from install.sql, and on an existing site.
  • PHP 8.3.33 and 8.5.9, reproduced on both.
  • MariaDB 10.4.24 with InnoDB and MariaDB 5.5.68 with MyISAM, reproduced on both.
  • No third-party modules installed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions