VYPR
Moderate severityGHSA Advisory· Published May 26, 2026· Updated May 26, 2026

Kirby CMS's `pages.access` permission is not checked during rendering of page drafts

CVE-2026-44176

Description

TL;DR

This vulnerability affects all Kirby sites where users of a particular role have no permission to access pages (pages.access permission is disabled). This can be due to configuration in the user blueprint(s), via options in the model blueprint(s) or via a combination of both settings.

Kirby sites are *not* affected if they intend all users of the site to be able to access all page drafts of the site. The vulnerability can only be exploited by authenticated users. Write actions are *not* affected by this vulnerability.

----

Introduction

Missing authorization allows authenticated users to perform actions they are not intended to have access to.

The effects of missing authorization can include unauthorized access to sensitive information as well as unauthorized changes to content or system information.

Affected components

Kirby's user permissions control which user role is allowed to perform specific actions to content models in the CMS. These permissions are defined for each role in the user blueprint (site/blueprints/users/...). It is also possible to customize the permissions for each target model in the model blueprints (such as in site/blueprints/pages/...) using the options feature. The permissions and options together control the authorization of user actions.

Kirby provides the pages.access and pages.list permissions (among others). The list permission controls whether affected models appear in lists throughout the Panel and REST API. The access permission has the same effect but also disables direct access to the affected models.

This vulnerability affects the path resolver for the main CMS router. The resolver takes an input path from the requested URL and determines which model (page or file) should be rendered. When a path is requested that points to a page draft, the resolver checks that the request either contains a valid preview token or is authenticated by a valid user.

Impact

In affected releases, Kirby allowed page drafts to be rendered if any valid user was authenticated, even if that user did not have access to the specific page model. Authenticated attackers with knowledge of the full path to an existing page draft could then access the rendered frontend page. This could lead to the disclosure of sensitive information, e.g. ahead of the launch of a new product or post.

Patches

The problem has been patched in Kirby 4.9.1 and Kirby 5.4.1. Please update to one of these or a later version to fix the vulnerability.

In all of the mentioned releases, Kirby has added a check that verifies that the requested page draft is accessible to the current user before rendering the draft template.

Credits

Kirby thank to @adrgs for responsibly reporting the identified issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Authenticated users can bypass the `pages.access` permission to view page drafts in Kirby CMS, leading to unauthorized information disclosure.

Vulnerability

The Kirby CMS path resolver fails to check the pages.access permission when rendering page drafts. In affected versions (Kirby 5.3.0 to 5.4.0 and Kirby 4.x before 4.9.1), the resolver only verifies that a valid user is authenticated or a preview token is present, but does not enforce the pages.access permission that may be disabled for certain roles via user blueprints or model blueprint options [2][3]. This affects sites where some roles are intentionally restricted from accessing pages.

Exploitation

An authenticated user with a role that has pages.access disabled can directly request the URL of a page draft (e.g., /pages/draft-slug) and the CMS will render it, bypassing the intended access control [3]. No special privileges or additional steps are required beyond authentication.

Impact

Successful exploitation allows an attacker to view the content of page drafts that they are not authorized to access, leading to unauthorized information disclosure. Write actions are not affected [2][3]. The confidentiality of draft content is compromised.

Mitigation

The vulnerability is fixed in Kirby 5.4.1 [1] and backported to Kirby 4.9.1 [4]. Users should upgrade to these versions or later. If upgrade is not possible, ensure that all authenticated users have the pages.access permission enabled, or restrict access to drafts via other means. No workaround is provided in the references.

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
getkirby/cmsPackagist
< 4.9.14.9.1
getkirby/cmsPackagist
>= 5.0.0, < 5.4.15.4.1

Affected products

2
  • Getkirby/KirbyGHSA2 versions
    >= 5.0.0, <= 5.4.0+ 1 more
    • (no CPE)range: >= 5.0.0, <= 5.4.0
    • (no CPE)range: <4.9.1, <5.4.1

Patches

5
238ef976af44

fix: Respect draft page access permission (#47)

https://github.com/getkirby/kirbyBastian AllgeierMay 5, 2026Fixed in 4.9.1via llm-release-walk
2 files changed · +180 1
  • src/Cms/App.php+1 1 modified
    @@ -1257,7 +1257,7 @@ public function resolve(
     		// search for a draft if the page cannot be found
     		if (!$page && $draft = $site->draft($path)) {
     			if (
    -				$this->user() ||
    +				($this->user() && $draft->isAccessible()) ||
     				$draft->isVerified($this->request()->get('token'))
     			) {
     				$page = $draft;
    
  • tests/Cms/App/AppResolveTest.php+179 0 modified
    @@ -436,6 +436,185 @@ public function testResolveMultilangPageRepresentation()
     		$this->assertSame('en', $app->language()->code());
     	}
     
    +	/**
    +	 * @covers ::resolve
    +	 */
    +	public function testResolveDraft(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$result = $app->resolve('test/a-draft');
    +		$this->assertNull($result);
    +	}
    +
    +	/**
    +	 * @covers ::resolve
    +	 */
    +	public function testResolveDraftWithUser(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'admin@getkirby.com', 'role' => 'admin']
    +			]
    +		]);
    +
    +		$app->impersonate('admin@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	/**
    +	 * @covers ::resolve
    +	 */
    +	public function testResolveDraftWithUserDeniedByPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$app->impersonate('editor@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertNull($result);
    +	}
    +
    +	/**
    +	 * @covers ::resolve
    +	 */
    +	public function testResolveDraftWithToken(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$draft = $app->page('test/a-draft');
    +		$token = $app->contentToken($draft, $draft->id() . $draft->template());
    +		$app   = $app->clone([
    +			'request' => [
    +				'query' => ['token' => $token]
    +			]
    +		]);
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	/**
    +	 * @covers ::resolve
    +	 */
    +	public function testResolveDraftWithTokenBypassesPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$draft = $app->page('test/a-draft');
    +		$token = $app->contentToken($draft, $draft->id() . $draft->template());
    +		$app   = $app->clone([
    +			'request' => [
    +				'query' => ['token' => $token]
    +			]
    +		]);
    +
    +		$app->impersonate('editor@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
     	/**
     	 * @covers ::resolve
     	 */
    
e9fbc07e4a5b

fix: Respect draft page access permission

https://github.com/getkirby/kirbyNico HoffmannMay 1, 2026Fixed in 5.4.1via llm-release-walk
2 files changed · +133 4
  • src/Cms/App.php+1 1 modified
    @@ -1262,7 +1262,7 @@ public function resolve(
     		// search for a draft if the page cannot be found
     		if (!$page && $draft = $site->draft($path)) {
     			if (
    -				$this->user() ||
    +				($this->user() && $draft->isAccessible()) ||
     				$draft->renderVersionFromRequest() !== null
     			) {
     				$page = $draft;
    
  • tests/Cms/App/AppResolveTest.php+132 3 modified
    @@ -99,15 +99,144 @@ public function testResolveDraft(): void
     
     		$result = $app->resolve('test/a-draft');
     		$this->assertNull($result);
    +	}
    +
    +	public function testResolveDraftWithUser(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'admin@getkirby.com', 'role' => 'admin']
    +			]
    +		]);
    +
    +		$app->impersonate('admin@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	public function testResolveDraftWithUserDeniedByPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$app->impersonate('editor@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertNull($result);
    +	}
     
    -		$app = $app->clone([
    +	public function testResolveDraftWithToken(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug' => 'test',
    +						'drafts' => [
    +							[
    +								'slug'  => 'a-draft',
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$token = $app->page('test/a-draft')->version()->previewToken();
    +		$app   = $app->clone([
     			'request' => [
    -				'query' => [
    -					'_token' => $app->page('test/a-draft')->version()->previewToken()
    +				'query' => ['_token' => $token]
    +			]
    +		]);
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	public function testResolveDraftWithTokenBypassesPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
     				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$token = $app->page('test/a-draft')->version()->previewToken();
    +		$app   = $app->clone([
    +			'request' => [
    +				'query' => ['_token' => $token]
     			]
     		]);
     
    +		$app->impersonate('editor@getkirby.com');
    +
     		$result = $app->resolve('test/a-draft');
     
     		$this->assertIsPage($result);
    
1b137d639b85

feat: New BlockCollectionAccess attribute for model methods

https://github.com/getkirby/kirbyBastian AllgeierMay 4, 2026Fixed in 4.9.1via llm-release-walk
14 files changed · +353 6
  • src/Cms/FileActions.php+11 0 modified
    @@ -7,6 +7,7 @@
     use Kirby\Exception\LogicException;
     use Kirby\Filesystem\F;
     use Kirby\Form\Form;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Uuid\Uuid;
     use Kirby\Uuid\Uuids;
     
    @@ -41,6 +42,7 @@ protected function changeExtension(
     	 *
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function changeName(
     		string $name,
     		bool $sanitize = true,
    @@ -100,6 +102,7 @@ public function changeName(
     	/**
     	 * Changes the file's sorting number in the meta file
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSort(int $sort): static
     	{
     		// skip if the sort number stays the same
    @@ -117,6 +120,7 @@ public function changeSort(int $sort): static
     	/**
     	 * @return $this|static
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTemplate(string|null $template): static
     	{
     		if ($template === $this->template()) {
    @@ -189,6 +193,7 @@ protected function commit(
     	 * Copy the file to the given page
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function copy(Page $page): static
     	{
     		F::copy($this->root(), $page->root() . '/' . $this->filename());
    @@ -313,6 +318,7 @@ public static function create(array $props, bool $move = false): File
     	 * Deletes the file. The store is used to
     	 * manipulate the filesystem or whatever you prefer.
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		return $this->commit('delete', ['file' => $this], function ($file) {
    @@ -335,6 +341,7 @@ public function delete(): bool
     	/**
     	 * Resizes/crops the original file with Kirby's thumb handler
     	 */
    +	#[BlockCollectionAccess]
     	public function manipulate(array|null $options = []): static
     	{
     		// nothing to process
    @@ -361,6 +368,7 @@ public function manipulate(array|null $options = []): static
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): static
     	{
     		Media::publish($this, $this->mediaRoot());
    @@ -377,6 +385,7 @@ public function publish(): static
     	 * @param bool $move If set to `true`, the source will be deleted
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function replace(string $source, bool $move = false): static
     	{
     		$file = $this->clone();
    @@ -429,6 +438,7 @@ public function save(
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function unpublish(bool $onlyMedia = false): static
     	{
     		// unpublish media files
    @@ -451,6 +461,7 @@ public function unpublish(bool $onlyMedia = false): static
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/File.php+7 0 modified
    @@ -8,6 +8,7 @@
     use Kirby\Filesystem\F;
     use Kirby\Filesystem\IsFile;
     use Kirby\Panel\File as Panel;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -134,6 +135,7 @@ public function __debugInfo(): array
     	 * Returns the url to api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		return $this->parent()->apiUrl($relative) . '/files/' . $this->filename();
    @@ -389,6 +391,7 @@ public function isReadable(): bool
     	 * Creates a unique media hash
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaHash(): string
     	{
     		return $this->mediaToken() . '-' . $this->modifiedFile();
    @@ -398,6 +401,7 @@ public function mediaHash(): string
     	 * Returns the absolute path to the file in the public media folder
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename();
    @@ -407,6 +411,7 @@ public function mediaRoot(): string
     	 * Creates a non-guessable token string for this file
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaToken(): string
     	{
     		$token = $this->kirby()->contentToken($this, $this->id());
    @@ -529,6 +534,7 @@ public function permissions(): FilePermissions
     	/**
     	 * Returns the absolute root to the file
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string|null
     	{
     		return $this->root ??= $this->parent()->root() . '/' . $this->filename();
    @@ -623,6 +629,7 @@ public function url(): string
     	 * option is used to disable this behavior or enable it
     	 * on a per-file basis.
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(): string|null
     	{
     		// check if the clean file URL is accessible,
    
  • src/Cms/HasFiles.php+2 0 modified
    @@ -2,6 +2,7 @@
     
     namespace Kirby\Cms;
     
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Uuid\Uuid;
     
     /**
    @@ -41,6 +42,7 @@ public function code(): Files
     	 *
     	 * @param bool $move If set to `true`, the source will be deleted
     	 */
    +	#[BlockCollectionAccess]
     	public function createFile(array $props, bool $move = false): File
     	{
     		$props = array_merge($props, [
    
  • src/Cms/HasMethods.php+1 1 modified
    @@ -53,7 +53,7 @@ public function hasMethod(string $method): bool
     	 * the current class or from a parent class ordered by
     	 * inheritance order (top to bottom)
     	 */
    -	protected function getMethod(string $method): Closure|null
    +	public function getMethod(string $method): Closure|null
     	{
     		if (isset(static::$methods[$method]) === true) {
     			return static::$methods[$method];
    
  • src/Cms/PageActions.php+18 0 modified
    @@ -10,6 +10,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Form\Form;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     use Kirby\Toolkit\Str;
     use Kirby\Uuid\Uuid;
    @@ -106,6 +107,7 @@ protected function adaptCopy(Page $copy, bool $files = false, bool $children = f
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeNum(int|null $num = null): static
     	{
     		if ($this->isDraft() === true) {
    @@ -148,6 +150,7 @@ public function changeNum(int|null $num = null): static
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the directory cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSlug(
     		string $slug,
     		string|null $languageCode = null
    @@ -247,6 +250,7 @@ protected function changeSlugForLanguage(
     	 * @param int|null $position Optional sorting number
     	 * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
     	 */
    +	#[BlockCollectionAccess]
     	public function changeStatus(string $status, int|null $position = null): static
     	{
     		return match ($status) {
    @@ -331,6 +335,7 @@ protected function changeStatusToUnlisted(): static
     	 *
     	 * @return $this|static
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSort(int|null $position = null): static
     	{
     		return $this->changeStatus('listed', $position);
    @@ -342,6 +347,7 @@ public function changeSort(int|null $position = null): static
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTemplate(string $template): static
     	{
     		if ($template === $this->intendedTemplate()->name()) {
    @@ -362,6 +368,7 @@ public function changeTemplate(string $template): static
     	/**
     	 * Change the page title
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTitle(
     		string $title,
     		string|null $languageCode = null
    @@ -435,6 +442,7 @@ protected function commit(
     	 *
     	 * @throws \Kirby\Exception\DuplicateException If the page already exists
     	 */
    +	#[BlockCollectionAccess]
     	public function copy(array $options = []): static
     	{
     		$slug        = $options['slug']      ?? $this->slug();
    @@ -567,6 +575,7 @@ function ($page, $props) use ($languageCode) {
     	/**
     	 * Creates a child of the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function createChild(array $props): Page
     	{
     		$props = array_merge($props, [
    @@ -584,6 +593,7 @@ public function createChild(array $props): Page
     	 * Create the sorting number for the page
     	 * depending on the blueprint settings
     	 */
    +	#[BlockCollectionAccess]
     	public function createNum(int|null $num = null): int
     	{
     		$mode = $this->blueprint()->num();
    @@ -641,6 +651,7 @@ public function createNum(int|null $num = null): int
     	/**
     	 * Deletes the page
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(bool $force = false): bool
     	{
     		return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
    @@ -690,6 +701,7 @@ public function delete(bool $force = false): bool
     	 * Duplicates the page with the given
     	 * slug and optionally copies all files
     	 */
    +	#[BlockCollectionAccess]
     	public function duplicate(string|null $slug = null, array $options = []): static
     	{
     		// create the slug for the duplicate
    @@ -722,6 +734,7 @@ public function duplicate(string|null $slug = null, array $options = []): static
     	 * Moves the page to a new parent if the
     	 * new parent accepts the page type
     	 */
    +	#[BlockCollectionAccess]
     	public function move(Site|Page $parent): Page
     	{
     		// nothing to move
    @@ -772,6 +785,7 @@ public function move(Site|Page $parent): Page
     	 * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): static
     	{
     		if ($this->isDraft() === false) {
    @@ -816,6 +830,7 @@ public function publish(): static
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function purge(): static
     	{
     		parent::purge();
    @@ -879,6 +894,7 @@ protected function resortSiblingsAfterListing(int|null $position = null): bool
     	/**
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function resortSiblingsAfterUnlisting(): bool
     	{
     		$index    = 0;
    @@ -927,6 +943,7 @@ public function save(
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function unpublish(): static
     	{
     		if ($this->isDraft() === true) {
    @@ -965,6 +982,7 @@ public function unpublish(): static
     	/**
     	 * Updates the page data
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/Page.php+10 0 modified
    @@ -13,6 +13,7 @@
     use Kirby\Panel\Page as Panel;
     use Kirby\Template\Template;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\LazyValue;
     use Kirby\Toolkit\Str;
     use Throwable;
    @@ -186,6 +187,7 @@ public function __debugInfo(): array
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -302,6 +304,7 @@ public function contentFileName(string|null $languageCode = null): string
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page`
     	 */
    +	#[BlockCollectionAccess]
     	public function controller(
     		array $data = [],
     		string $contentType = 'html'
    @@ -426,6 +429,7 @@ public static function factory($props): static
     	 * @param array $options Options for `Kirby\Http\Uri` to create URL parts
     	 * @param int $code HTTP status code
     	 */
    +	#[BlockCollectionAccess]
     	public function go(array $options = [], int $code = 302): void
     	{
     		Response::go($this->url($options), $code);
    @@ -475,6 +479,7 @@ public function intendedTemplate(): Template
     	 * children and content files
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -797,6 +802,7 @@ public function isVerified(string|null $token = null): bool
     	 * Returns the root to the media folder for the page
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->kirby()->root('media') . '/pages/' . $this->id();
    @@ -934,6 +940,7 @@ public function permissions(): PagePermissions
     	 * Draft preview Url
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(): string|null
     	{
     		$preview = $this->blueprint()->preview();
    @@ -967,6 +974,7 @@ public function previewUrl(): string|null
     	 * @param string $contentType
     	 * @throws \Kirby\Exception\NotFoundException If the default template cannot be found
     	 */
    +	#[BlockCollectionAccess]
     	public function render(array $data = [], $contentType = 'html'): string
     	{
     		$kirby = $this->kirby();
    @@ -1047,6 +1055,7 @@ public function render(array $data = [], $contentType = 'html'): string
     	 * @internal
     	 * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found
     	 */
    +	#[BlockCollectionAccess]
     	public function representation(mixed $type): Template
     	{
     		$kirby          = $this->kirby();
    @@ -1064,6 +1073,7 @@ public function representation(mixed $type): Template
     	 * Returns the absolute root to the page directory
     	 * No matter if it exists or not.
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root ??= $this->kirby()->root('content') . '/' . $this->diruri();
    
  • src/Cms/SiteActions.php+4 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Closure;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * SiteActions
    @@ -47,6 +48,7 @@ protected function commit(
     	/**
     	 * Change the site title
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTitle(
     		string $title,
     		string|null $languageCode = null
    @@ -74,6 +76,7 @@ public function changeTitle(
     	/**
     	 * Creates a main page
     	 */
    +	#[BlockCollectionAccess]
     	public function createChild(array $props): Page
     	{
     		$props = array_merge($props, [
    @@ -91,6 +94,7 @@ public function createChild(array $props): Page
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function purge(): static
     	{
     		parent::purge();
    
  • src/Cms/Site.php+7 0 modified
    @@ -7,6 +7,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Panel\Site as Panel;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * The `$site` object is the root element
    @@ -140,6 +141,7 @@ public function __toString(): string
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -249,6 +251,7 @@ public function homePageId(): string
     	 * and children in the site directory
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -292,6 +295,7 @@ public function isAccessible(): bool
     	 * Returns the root to the media folder for the site
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->kirby()->root('media') . '/site';
    @@ -374,6 +378,7 @@ public function permissions(): SitePermissions
     	 * Preview Url
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(): string|null
     	{
     		$preview = $this->blueprint()->preview();
    @@ -394,6 +399,7 @@ public function previewUrl(): string|null
     	/**
     	 * Returns the absolute path to the content directory
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root ??= $this->kirby()->root('content');
    @@ -482,6 +488,7 @@ public function urlForLanguage(
     	 * returns the current page
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function visit(
     		string|Page $page,
     		string|null $languageCode = null
    
  • src/Cms/UserActions.php+13 0 modified
    @@ -12,6 +12,7 @@
     use Kirby\Form\Form;
     use Kirby\Http\Idn;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use SensitiveParameter;
     use Throwable;
    @@ -30,6 +31,7 @@ trait UserActions
     	/**
     	 * Changes the user email address
     	 */
    +	#[BlockCollectionAccess]
     	public function changeEmail(string $email): static
     	{
     		$email = trim($email);
    @@ -53,6 +55,7 @@ public function changeEmail(string $email): static
     	/**
     	 * Changes the user language
     	 */
    +	#[BlockCollectionAccess]
     	public function changeLanguage(string $language): static
     	{
     		return $this->commit('changeLanguage', ['user' => $this, 'language' => $language], function ($user, $language) {
    @@ -74,6 +77,7 @@ public function changeLanguage(string $language): static
     	/**
     	 * Changes the screen name of the user
     	 */
    +	#[BlockCollectionAccess]
     	public function changeName(string $name): static
     	{
     		$name = trim($name);
    @@ -100,6 +104,7 @@ public function changeName(string $name): static
     	 * If this method is used with user input, it is recommended to also
     	 * confirm the current password by the user via `::validatePassword()`
     	 */
    +	#[BlockCollectionAccess]
     	public function changePassword(
     		#[SensitiveParameter]
     		string $password
    @@ -128,6 +133,7 @@ public function changePassword(
     	/**
     	 * Changes the user role
     	 */
    +	#[BlockCollectionAccess]
     	public function changeRole(string $role): static
     	{
     		return $this->commit('changeRole', ['user' => $this, 'role' => $role], function ($user, $role) {
    @@ -150,6 +156,7 @@ public function changeRole(string $role): static
     	 * Changes the user's TOTP secret
     	 * @since 4.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTotp(
     		#[SensitiveParameter]
     		string|null $secret
    @@ -273,6 +280,7 @@ public static function create(array|null $props = null): User
     	/**
     	 * Creates a new avatar for the user
     	 */
    +	#[BlockCollectionAccess]
     	public function createAvatar(string $source, string $extension, bool $move = false): static
     	{
     		return $this->commit('createAvatar', ['user' => $this, 'source' => $source, 'extension' => $extension], function ($user, $source, $extension) use ($move) {
    @@ -292,6 +300,7 @@ public function createAvatar(string $source, string $extension, bool $move = fal
     	/**
     	 * Returns a random user id
     	 */
    +	#[BlockCollectionAccess]
     	public function createId(): string
     	{
     		$length = 8;
    @@ -317,6 +326,7 @@ public function createId(): string
     	 *
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		return $this->commit('delete', ['user' => $this], function ($user) {
    @@ -342,6 +352,7 @@ public function delete(): bool
     	/**
     	 * Deletes the existing avatar if it exists
     	 */
    +	#[BlockCollectionAccess]
     	public function deleteAvatar(): bool
     	{
     		return $this->commit('deleteAvatar', ['user' => $this], function ($user) {
    @@ -403,6 +414,7 @@ protected function readSecrets(): array
     	/**
     	 * Replaces the existing avatar for the user
     	 */
    +	#[BlockCollectionAccess]
     	public function replaceAvatar(string $source, string $extension, bool $move = false): static
     	{
     		return $this->commit('replaceAvatar', ['user' => $this, 'source' => $source, 'extension' => $extension], function ($user, $source, $extension) use ($move) {
    @@ -442,6 +454,7 @@ public function replaceAvatar(string $source, string $extension, bool $move = fa
     	/**
     	 * Updates the user data
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/User.php+13 0 modified
    @@ -12,6 +12,7 @@
     use Kirby\Filesystem\F;
     use Kirby\Panel\User as Panel;
     use Kirby\Session\Session;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use SensitiveParameter;
     
    @@ -126,6 +127,7 @@ public function __debugInfo(): array
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -237,6 +239,7 @@ public static function factory(mixed $props): static
     	 * which will leave it as `null`
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public static function hashPassword(
     		#[SensitiveParameter]
     		string|null $password = null
    @@ -260,6 +263,7 @@ public function id(): string
     	 * Returns the inventory of files
     	 * children and content files
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -387,6 +391,7 @@ public function language(): string
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function login(
     		#[SensitiveParameter]
     		string $password,
    @@ -403,6 +408,7 @@ public function login(
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function loginPasswordless(
     		Session|array|null $session = null
     	): void {
    @@ -438,6 +444,7 @@ public function loginPasswordless(
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to unset the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function logout(Session|array|null $session = null): void
     	{
     		$kirby   = $this->kirby();
    @@ -469,6 +476,7 @@ public function logout(Session|array|null $session = null): void
     	 * Returns the root to the media folder for the user
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->kirby()->root('media') . '/users/' . $this->id();
    @@ -560,6 +568,7 @@ public function panel(): Panel
     	/**
     	 * Returns the encrypted user password
     	 */
    +	#[BlockCollectionAccess]
     	public function password(): string|null
     	{
     		return $this->password ??= $this->readPassword();
    @@ -569,6 +578,7 @@ public function password(): string|null
     	 * Returns the timestamp when the password
     	 * was last changed
     	 */
    +	#[BlockCollectionAccess]
     	public function passwordTimestamp(): int|null
     	{
     		$file = $this->secretsFile();
    @@ -657,6 +667,7 @@ public function roles(): Roles
     	/**
     	 * The absolute path to the user directory
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->kirby()->root('accounts') . '/' . $this->id();
    @@ -675,6 +686,7 @@ protected function rules(): UserRules
     	 * Reads a specific secret from the user secrets file on disk
     	 * @since 4.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function secret(string $key): mixed
     	{
     		return $this->readSecrets()[$key] ?? null;
    @@ -769,6 +781,7 @@ public function username(): string|null
     	 * @throws \Kirby\Exception\InvalidArgumentException If the entered password is not valid
     	 *                                                   or does not match the user password
     	 */
    +	#[BlockCollectionAccess]
     	public function validatePassword(
     		#[SensitiveParameter]
     		string|null $password = null
    
  • src/Toolkit/BlockCollectionAccess.php+22 0 added
    @@ -0,0 +1,22 @@
    +<?php
    +
    +namespace Kirby\Toolkit;
    +
    +use Attribute;
    +
    +/**
    + * Marks a method as blocked from collection operations such as
    + * filterBy/sortBy/group/pluck/findBy to prevent sensitive data
    + * exposure (e.g. password hashes) or unintended write actions
    + * through queries driven by user input.
    + *
    + * @package   Kirby Toolkit
    + * @author    Bastian Allgeier <bastian@getkirby.com>
    + * @link      https://getkirby.com
    + * @copyright Bastian Allgeier
    + * @license   https://getkirby.com/license
    + */
    +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
    +class BlockCollectionAccess
    +{
    +}
    
  • src/Toolkit/Collection.php+48 5 modified
    @@ -5,6 +5,8 @@
     use Closure;
     use Countable;
     use Exception;
    +use ReflectionFunction;
    +use ReflectionMethod;
     
     /**
      * The collection class provides a nicer
    @@ -508,12 +510,53 @@ protected function getAttributeFromArray(array $array, string $attribute)
     	}
     
     	/**
    -	 * @param object $object
    -	 * @param string $attribute
    -	 * @return mixed
    +	 * Blocks access to methods that are marked with the
    +	 * #[BlockCollectionAccess] attribute to prevent sensitive data
    +	 * exposure (e.g. password hashes) or unintended write actions
    +	 * through collection operations driven by user input.
    +	 *
    +	 * This applies to both explicit PHP methods and closures registered
    +	 * via HasMethods::$methods. Attributes resolved via __call() that
    +	 * have no matching PHP method or registered closure (i.e. content
    +	 * fields) are always allowed through.
     	 */
     	protected function getAttributeFromObject($object, string $attribute)
     	{
    +		static $cache = [];
    +		$key = $object::class . '::' . strtolower($attribute);
    +
    +		if (isset($cache[$key]) === false) {
    +			if (method_exists($object, $attribute) === true) {
    +				// explicit PHP method: check for #[BlockCollectionAccess] via reflection
    +				$cache[$key] = (new ReflectionMethod($object, $attribute))
    +					->getAttributes(BlockCollectionAccess::class) === [];
    +			} elseif (method_exists($object, 'hasMethod') === true && $object->hasMethod($attribute) === true) {
    +				// closure registered via HasMethods::$methods: check the closure's attributes
    +				$closure = $object->getMethod($attribute);
    +				$cache[$key] = $closure === null ||
    +					(new ReflectionFunction($closure))
    +						->getAttributes(BlockCollectionAccess::class) === [];
    +			} else {
    +				// no PHP method and no registered closure (e.g. a CMS content field
    +				// resolved via __call()): always allow through
    +				$cache[$key] = true;
    +			}
    +		}
    +
    +		if ($cache[$key] === false) {
    +			// throw in debug mode so developers get a clear signal instead of a silent null
    +			if (
    +				class_exists('Kirby\Cms\App', false) === true &&
    +				\Kirby\Cms\App::instance(lazy: true)?->option('debug') === true
    +			) {
    +				throw new \InvalidArgumentException(
    +					'The "' . $attribute . '" method is not accessible in collection operations.'
    +				);
    +			}
    +
    +			return null;
    +		}
    +
     		return $object->{$attribute}();
     	}
     
    @@ -1271,15 +1314,15 @@ public function without(...$keys)
      * Contains Filter
      */
     Collection::$filters['*='] = [
    -	'validator' => fn ($value, $test) => strpos($value, $test) !== false,
    +	'validator' => fn ($value, $test) => $value !== null && str_contains($value, $test) === true,
     	'strict'    => false
     ];
     
     /**
      * Not Contains Filter
      */
     Collection::$filters['!*='] = [
    -	'validator' => fn ($value, $test) => strpos($value, $test) === false
    +	'validator' => fn ($value, $test) => $value === null || str_contains($value, $test) === false
     ];
     
     /**
    
  • tests/Cms/Collections/CollectionTest.php+122 0 modified
    @@ -4,6 +4,7 @@
     
     use Exception;
     use Kirby\Content\Field;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Obj;
     use stdClass;
     
    @@ -43,6 +44,12 @@ class CollectionTest extends TestCase
     {
     	public const TMP = KIRBY_TMP_DIR . '/Cms.Collection';
     
    +	public function tearDown(): void
    +	{
    +		parent::tearDown();
    +		unset(User::$methods['allowedCustomMethod'], User::$methods['blockedCustomMethod']);
    +	}
    +
     	public function testCollectionMethods()
     	{
     		$kirby = $this->kirby([
    @@ -113,6 +120,121 @@ public function testGetAttributeWithField()
     		$this->assertSame($field, $value);
     	}
     
    +	public function testGetAttributeFromObjectBlocksRegisteredClosure(): void
    +	{
    +		User::$methods['blockedCustomMethod'] = #[BlockCollectionAccess] function () {
    +			return 'sensitive data';
    +		};
    +
    +		User::$methods['allowedCustomMethod'] = function () {
    +			return 'safe data';
    +		};
    +
    +		$users = Users::factory([['email' => 'test@getkirby.com']]);
    +		$user  = $users->first();
    +
    +		// closure with #[BlockCollectionAccess] must be blocked
    +		$this->assertNull($users->getAttribute($user, 'blockedCustomMethod'));
    +
    +		// closure without #[BlockCollectionAccess] must be allowed
    +		$this->assertSame('safe data', $users->getAttribute($user, 'allowedCustomMethod'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlocksSensitiveMethod(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'    => 'test@getkirby.com',
    +				'password' => 'supersecret'
    +			]
    +		]);
    +
    +		$user = $users->first();
    +
    +		// password() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'password'));
    +
    +		// passwordTimestamp() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'passwordTimestamp'));
    +
    +		// secret() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'secret'));
    +	}
    +
    +	public function testGetAttributeFromObjectAllowsContentField(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'   => 'test@getkirby.com',
    +				'content' => ['bio' => 'hello world']
    +			]
    +		]);
    +
    +		$user = $users->first();
    +
    +		// content fields accessed via __call() must always be allowed
    +		$this->assertSame('hello world', (string)$users->getAttribute($user, 'bio'));
    +	}
    +
    +	public function testGetAttributeFromObjectAllowsAccessibleField(): void
    +	{
    +		$users = Users::factory([
    +			['email' => 'test@getkirby.com']
    +		]);
    +
    +		$user = $users->first();
    +
    +		// email() has no #[BlockCollectionAccess] - must be allowed
    +		$this->assertSame('test@getkirby.com', $users->getAttribute($user, 'email'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlockedInDebugMode(): void
    +	{
    +		new App([
    +			'roots'   => ['index' => '/dev/null'],
    +			'options' => ['debug' => true]
    +		]);
    +
    +		$users = Users::factory([
    +			['email' => 'test@getkirby.com', 'password' => 'supersecret']
    +		]);
    +
    +		$user = $users->first();
    +
    +		$this->expectException(\InvalidArgumentException::class);
    +		$users->getAttribute($user, 'password');
    +	}
    +
    +	public function testGetAttributeFromObjectWithoutAccessibleFields(): void
    +	{
    +		// MockObject methods don't have #[BlockCollectionAccess] - access must be allowed
    +		$collection = new Collection([
    +			$obj = new MockObject(['id' => 'a', 'group' => 'x'])
    +		]);
    +
    +		$this->assertSame('a', $collection->getAttribute($obj, 'id'));
    +		$this->assertSame('x', $collection->getAttribute($obj, 'group'));
    +	}
    +
    +	public function testFilterByPasswordIsBlocked(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'    => 'a@getkirby.com',
    +				'password' => '$2y$10$abcdefghijklmnopqrstuvwxyABCDEFGHIJKLMNOPQRSTUVWXYZ01234'
    +			],
    +			[
    +				'email'    => 'b@getkirby.com',
    +				'password' => '$2y$10$zzzzzzzzzzzzzzzzzzzzzzzzABCDEFGHIJKLMNOPQRSTUVWXYZ01234'
    +			]
    +		]);
    +
    +		// filtering by password must return no match (null != any value)
    +		// rather than exposing hash contents via binary oracle
    +		$result = $users->filter('password', '*=', '$2y$');
    +		$this->assertCount(0, $result);
    +	}
    +
     	public function testAppend()
     	{
     		$a = new MockObject(['id' => 'a', 'name' => 'A']);
    
  • tests/Toolkit/CollectionTest.php+75 0 modified
    @@ -2,6 +2,51 @@
     
     namespace Kirby\Toolkit;
     
    +class AccessibleObject
    +{
    +	public function value(): string
    +	{
    +		return 'accessible';
    +	}
    +}
    +
    +class BlockedObject
    +{
    +	#[BlockCollectionAccess]
    +	public function secret(): string
    +	{
    +		return 'sensitive';
    +	}
    +}
    +
    +class HasMethodsObject
    +{
    +	public static array $methods = [];
    +
    +	public function __call(string $name, array $args): mixed
    +	{
    +		return $this->getMethod($name)?->call($this, ...$args);
    +	}
    +
    +	public function getMethod(string $method): \Closure|null
    +	{
    +		return static::$methods[$method] ?? null;
    +	}
    +
    +	public function hasMethod(string $method): bool
    +	{
    +		return isset(static::$methods[$method]);
    +	}
    +}
    +
    +class MagicCallObject
    +{
    +	public function __call(string $name, array $args): string
    +	{
    +		return 'magic';
    +	}
    +}
    +
     class StringObject
     {
     	protected $value;
    @@ -253,6 +298,36 @@ public function testGetAttributeFromObject()
     		$this->assertSame('Marge', $collection->getAttribute($collection->last(), 'username'));
     	}
     
    +	public function testGetAttributeFromObjectAccessibleMethod(): void
    +	{
    +		$obj        = new AccessibleObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('accessible', $collection->getAttribute($obj, 'value'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlockedMethod(): void
    +	{
    +		$obj        = new BlockedObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertNull($collection->getAttribute($obj, 'secret'));
    +	}
    +
    +	public function testGetAttributeFromObjectViaHasMethods(): void
    +	{
    +		HasMethodsObject::$methods['custom'] = fn () => 'custom value';
    +		$obj        = new HasMethodsObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('custom value', $collection->getAttribute($obj, 'custom'));
    +		HasMethodsObject::$methods = [];
    +	}
    +
    +	public function testGetAttributeFromObjectViaMagicCall(): void
    +	{
    +		$obj        = new MagicCallObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('magic', $collection->getAttribute($obj, 'anything'));
    +	}
    +
     	/**
     	 * @covers ::__get
     	 * @covers ::__call
    
7bf0194141e5

fix: Prevent path traversal during user lookup

https://github.com/getkirby/kirbyLukas BestleMay 1, 2026Fixed in 5.4.1via llm-release-walk
2 files changed · +38 1
  • src/Cms/Users.php+8 1 modified
    @@ -7,6 +7,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Filesystem\F;
     use Kirby\Toolkit\Str;
    +use Kirby\Toolkit\V;
     use Kirby\Uuid\HasUuids;
     
     /**
    @@ -159,11 +160,17 @@ protected function hydrateElement(string $key): User|null
     			return null;
     		}
     
    +		// ensure the user ID only contains safe characters
    +		// to prevent path traversal into subfolders
    +		if (V::match($key, '/^([a-z0-9_-])+$/i') !== true) {
    +			return null;
    +		}
    +
     		// check if the user directory exists if not all keys have been
     		// populated in the collection, otherwise we can assume that
     		// this method will only be called on "unhydrated" user IDs
     		$root = $this->root . '/' . $key;
    -		if ($this->initialized === false && is_dir($root) === false) {
    +		if ($this->initialized === false && Dir::exists($root, $this->root) === false) {
     			return null;
     		}
     
    
  • tests/Cms/Users/UsersTest.php+30 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Filesystem\F;
     
     class UsersTest extends TestCase
     {
    @@ -225,6 +226,35 @@ public function testFindInFilesystem(): void
     		$this->assertNull($users->find('user://bar'));
     	}
     
    +	public function testFindPathTraversal(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'accounts' => static::TMP . '/accounts',
    +				'index'    => '/dev/null'
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		$app->users()->create(['id' => 'homer', 'email' => 'a@getkirby.com', 'password' => '12345678']);
    +
    +		$contents = '<?php throw new PHPUnit\Framework\AssertionFailedError("File was accessible via path traversal");';
    +		F::write(static::TMP . '/index.php', $contents);
    +		F::write(static::TMP . '/accounts/homer/subfolder/index.php', $contents);
    +
    +		// initialize a new fresh app instance to start with an empty collection
    +		$app   = $app->clone();
    +		$users = $app->users();
    +
    +		// block slashes in user IDs to prevent disclosure of path structures and access to subfolders
    +		$this->assertNull($users->find('../accounts/homer'));
    +		$this->assertNull($users->find('homer/subfolder'));
    +
    +		// block path traversal outside of the `accounts` directory
    +		$this->assertNull($users->find('..'));
    +	}
    +
     	public function testCustomMethods(): void
     	{
     		Users::$methods = [
    
aa0b068a5c44

fix: Sanitize list field values

https://github.com/getkirby/kirbyNico HoffmannMay 1, 2026Fixed in 4.9.1via llm-release-walk
5 files changed · +48 9
  • config/fields/list.php+5 1 modified
    @@ -1,5 +1,7 @@
     <?php
     
    +use Kirby\Sane\Sane;
    +
     return [
     	'props' => [
     		/**
    @@ -17,7 +19,9 @@
     	],
     	'computed' => [
     		'value' => function () {
    -			return trim($this->value ?? '');
    +			$value = trim($this->value ?? '');
    +			$value = Sane::sanitizeProseMirrorFields($value);
    +			return $value;
     		}
     	]
     ];
    
  • config/fields/writer.php+1 7 modified
    @@ -63,13 +63,7 @@
     	'computed' => [
     		'value' => function () {
     			$value = trim($this->value ?? '');
    -			$value = Sane::sanitize($value, 'html');
    -
    -			// convert non-breaking spaces to HTML entity
    -			// as that's how ProseMirror handles it internally;
    -			// will allow comparing saved and current content
    -			$value = str_replace(' ', '&nbsp;', $value);
    -
    +			$value = Sane::sanitizeProseMirrorFields($value);
     			return $value;
     		}
     	],
    
  • src/Sane/Sane.php+18 0 modified
    @@ -131,6 +131,24 @@ public static function sanitizeFile(
     		}
     	}
     
    +	/**
    +	 * Sanitizes the given string from ProseMirror-backed fields
    +	 * @since 4.9.1
    +	 */
    +	public static function sanitizeProseMirrorFields(
    +		string $string,
    +		bool $isExternal = false
    +	): string {
    +		$string = static::sanitize($string, 'html', $isExternal);
    +
    +		// convert non-breaking spaces to HTML entity
    +		// as that's how ProseMirror handles it internally;
    +		// will allow comparing saved and current content
    +		$string = str_replace(' ', '&nbsp;', $string);
    +
    +		return $string;
    +	}
    +
     	/**
     	 * Validates file contents with the specified handler
     	 *
    
  • tests/Form/Fields/ListFieldTest.php+9 0 modified
    @@ -15,4 +15,13 @@ public function testDefaultProps()
     		$this->assertNull($field->text());
     		$this->assertTrue($field->save());
     	}
    +
    +	public function testValueSanitized(): void
    +	{
    +		$field = $this->field('list', [
    +			'value' => '<ul><li>Item <strong>one</strong></li></ul><script>alert("Hacked")</script>'
    +		]);
    +
    +		$this->assertSame('<ul><li>Item <strong>one</strong></li></ul>', $field->value());
    +	}
     }
    
  • tests/Sane/SaneTest.php+15 1 modified
    @@ -169,10 +169,24 @@ public function testSanitizeFileMultipleHandlersExplicit()
     		$this->assertFileEquals($expected, $tmp);
     	}
     
    +	/**
    +	 * @covers ::sanitizeProseMirrorFields
    +	 */
    +	public function testSanitizeProseMirrorFields(): void
    +	{
    +		$this->assertSame(
    +			'This is a <strong>test</strong> with <em>formatting</em>',
    +			Sane::sanitizeProseMirrorFields('This is a <strong>test</strong><script>alert("Hacked")</script> with <em>formatting</em>')
    +		);
    +
    +		// non-breaking spaces are converted to HTML entities
    +		$this->assertSame('foo&nbsp;bar', Sane::sanitizeProseMirrorFields("foo\u{00A0}bar"));
    +	}
    +
     	/**
     	 * @covers ::validate
     	 */
    -	public function testValidate()
    +	public function testValidate(): void
     	{
     		$this->assertNull(Sane::validate('<svg></svg>', 'svg'));
     	}
    

Vulnerability mechanics

Root cause

"Missing authorization check in the path resolver allows any authenticated user to view page drafts regardless of their `pages.access` permission."

Attack vector

An authenticated attacker who knows the full path to an existing page draft can access the rendered frontend page even if their role has `pages.access` set to `false`. The resolver previously allowed any authenticated user to view any draft, bypassing the access permission configured in user blueprints or model blueprints. The attacker only needs to be logged in and request the draft's URL; no special payload or crafted request is required beyond normal authentication.

Affected code

The vulnerability is in the `resolve()` method of `src/Cms/App.php`. The path resolver checks whether a user is authenticated (`$this->user()`) before rendering a draft page, but it did not verify whether that user had the `pages.access` permission for the specific draft. The patch adds a call to `$draft->isAccessible()` to enforce the permission check [patch_id=2595569][patch_id=2595568].

What the fix does

Both patches change the same conditional in `src/Cms/App.php`: the `$this->user()` check is extended to `($this->user() && $draft->isAccessible())`. This ensures that even if a user is authenticated, the draft is only rendered if the user's role has the `pages.access` permission for that specific page. The preview-token path remains unchanged, so unauthenticated previews via token still work as intended [patch_id=2595569][patch_id=2595568].

Preconditions

  • authAttacker must be an authenticated user of the Kirby site
  • configThe site must have at least one user role with pages.access permission disabled
  • inputAttacker must know the full URL path to an existing page draft

Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.