VYPR
High severity8.4GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

Kirby CMS vulnerable to cross-site scripting (XSS) from links in KirbyTags and image blocks in the site frontend

CVE-2026-45368

Description

TL;DR

This vulnerability affects all Kirby sites that allow the use of the (link: …) KirbyTag, the link: parameter of the (image: …) KirbyTag, the built-in image block with a link or the HTML importer for blocks, when content is authored by users who may not be fully trusted. The attack requires an authenticated Panel user with update permission to any textarea or blocks field, or write access to content files through another vector (e.g. a frontend form or content sync pipeline). Another attack vector is the use of Html::a() or Html::link() with untrusted user input.

This vulnerability is of high severity for affected sites.

Kirby sites are *not* affected if none of the mentioned KirbyTags or block types are used, or if every user who can edit content is fully trusted. The attack only surfaces in the site frontend (i.e. in its templates). The Panel itself is unaffected and will not execute JavaScript that was injected into the textarea or blocks field content.

---

Introduction

Cross-site scripting (XSS) is a type of vulnerability that allows to execute any kind of JavaScript code inside the site frontend or Panel session of the same or other users. In the Panel, a harmful script can for example trigger requests to Kirby's API with the permissions of the victim.

In a *stored* XSS attack, the malicious payload is saved into the content data and has the potential to affect other users or site visitors.

Such vulnerabilities are critical if a consuming application might have potential attackers in its group of authenticated Panel users. They can escalate their privileges if they get access to the Panel session of an admin user. Depending on the site, other JavaScript-powered attacks are possible.

A specific class of stored XSS exploits the javascript: URI scheme in HTML ` attributes. When a browser processes a click action on a link with href="javascript:…"`, it executes the value as JavaScript in the origin of the current page. Because the site usually runs on the same origin as the Panel API, a successful exploit in the site frontend can give the attacker full control of the victim's Panel session.

Affected components

Kirby provides four first-party renderers that produce `` output from editor-supplied field values:

  1. The (link: …) KirbyTag
  2. The link: parameter of the (image: …) KirbyTag, when the parameter does not resolve to a known file or 'self'
  3. The link field of the built-in image block
  4. The HTML importer for the blocks field (which accepted the same malicious input as the image block link field)

Impact

In affected releases, the underlying URL methods for these components did not filter out malicious URL values that resolve to script execution. While simple javascript: URLs were already deactivated by treating them as a relative path and prepending a single slash to the URL, the use of URLs of the format javascript://x%0A… bypasses this protection. The vbscript:, data:, livescript:, mocha: and jar: schemes are affected by the same underlying gap.

The vulnerability allows attackers to inject malicious links into content. The malicious links would then be rendered on the site frontend. If a site visitor or logged in user browsing the site would click such a link, the malicious script code would then be executed in the browser.

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, a new Url::hasDangerousScheme() method detects URI schemes that must never appear in a rendered href or src attribute (javascript:, vbscript:, livescript:, mocha:, jar:, data:).

Url::isAbsolute() now returns false for any URL that hasDangerousScheme() identifies as dangerous, so the URL component no longer passes these values through makeAbsolute() unchanged.

Html::link() now replaces the href with an empty string when a dangerous scheme is detected, so the rendered `` tag links back to the current page rather than executing the injected script.

The HTML importer for blocks strips link targets with a dangerous scheme.

Due to the hardening in these underlying URL methods, the affected KirbyTags and block no longer allow dangerous schemes in link targets.

Credits

Kirby thanks @offset for responsibly reporting the identified issue.

AI Insight

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

Stored XSS in Kirby via `(link:)` KirbyTag, `(image:)` link parameter, image block links, and HTML importer allows authenticated attackers to execute arbitrary JavaScript in the frontend.

Vulnerability

A stored cross-site scripting (XSS) vulnerability exists in Kirby CMS versions prior to 5.4.1 (and prior to 4.9.1 for the 4.x branch) [1][2][3][4]. The vulnerability is triggered when the (link: …) KirbyTag, the link: parameter of the (image: …) KirbyTag, the built-in image block with a link, or the HTML importer for blocks is used with untrusted input. An authenticated Panel user with update permission to any textarea or blocks field, or write access through other vectors like a frontend form or content sync pipeline, can inject a malicious javascript: URI scheme into the href attribute of a rendered link.

Exploitation

An attacker must be an authenticated Panel user with update permissions to a textarea or blocks field, or have write access to content files via other means. The attacker crafts a link using one of the affected KirbyTags or block features, setting the link value to javascript:malicious_code. When a victim clicks the rendered link in the frontend, the browser executes the JavaScript in the context of the site origin. No additional user interaction beyond the click is required [2][3].

Impact

Successful exploitation allows the attacker to execute arbitrary JavaScript on the site frontend. Because the frontend typically runs on the same origin as the Panel API, the attacker can potentially hijack an admin user's session, escalating privileges and performing actions like reading, modifying, or deleting content, or accessing sensitive data. The vulnerability is rated high severity with a CVSS score reflecting the potential for full site compromise [2][3].

Mitigation

Fixed in Kirby 5.4.1 (released 2026-05-27) [1] and backported to Kirby 4.9.1 [4]. All users should upgrade immediately. As a workaround, restrict the use of (link:) KirbyTags, (image:) with link, image block links, and the HTML importer to trusted editors only. Alternatively, ensure no untrusted user has update access to content fields. The vulnerability is not known to be listed in CISA's KEV catalog.

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 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 and <5.4.1

Patches

16
164c4966dcae

fix: Apply block attribute on `Form` classes

https://github.com/getkirby/kirbyLukas BestleMay 14, 2026Fixed in 4.9.1via llm-release-walk
2 files changed · +4 0
  • src/Form/Field/BlocksField.php+2 0 modified
    @@ -15,6 +15,7 @@
     use Kirby\Form\Mixin\EmptyState;
     use Kirby\Form\Mixin\Max;
     use Kirby\Form\Mixin\Min;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -104,6 +105,7 @@ public function fieldsetGroups(): array|null
     		return empty($groups) === true ? null : $groups;
     	}
     
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value = null): void
     	{
     		$value  = BlocksCollection::parse($value);
    
  • src/Form/Field/LayoutField.php+2 0 modified
    @@ -9,6 +9,7 @@
     use Kirby\Cms\Layouts;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Form\Form;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -28,6 +29,7 @@ public function __construct(array $params)
     		parent::__construct($params);
     	}
     
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value = null): void
     	{
     		$value   = $this->valueFromJson($value);
    
493b7e70d433

fix: Filter dangerous hrefs in Parsley HTML import (#57)

https://github.com/getkirby/kirbyAhmet BoraMay 13, 2026Fixed in 4.9.1via llm-release-walk
5 files changed · +79 1
  • src/Parsley/Inline.php+8 1 modified
    @@ -6,6 +6,7 @@
     use DOMNode;
     use DOMNodeList;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Toolkit\Html;
     
     /**
    @@ -64,10 +65,16 @@ public static function parseAttrs(
     		$defaults = $mark['defaults'] ?? [];
     
     		foreach ($mark['attrs'] ?? [] as $attr) {
    -			$attrs[$attr] = match ($node->hasAttribute($attr)) {
    +			$value = match ($node->hasAttribute($attr)) {
     				true    => $node->getAttribute($attr),
     				default => $defaults[$attr] ?? null
     			};
    +
    +			if ($attr === 'href' && Url::hasDangerousScheme($value) === true) {
    +				continue;
    +			}
    +
    +			$attrs[$attr] = $value;
     		}
     
     		return $attrs;
    
  • src/Parsley/Schema/Blocks.php+5 0 modified
    @@ -4,6 +4,7 @@
     
     use DOMElement;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Parsley\Element;
     use Kirby\Toolkit\Str;
     
    @@ -156,6 +157,10 @@ public function img(Element $node): array
     		$figcaption = $node->find('ancestor::figure[1]//figcaption');
     		$caption    = $figcaption?->innerHTML($this->marks());
     
    +		if (Url::hasDangerousScheme($link) === true) {
    +			$link = null;
    +		}
    +
     		// avoid parsing the caption twice
     		$figcaption?->remove();
     
    
  • tests/Cms/Blocks/BlocksTest.php+8 0 modified
    @@ -147,6 +147,14 @@ public function testParseString()
     		$this->assertSame($expected, $result);
     	}
     
    +	public function testParseSkipsDangerousHref()
    +	{
    +		$value  = Blocks::parse('<a href="javascript://x%0Aalert(1)">Test</a>');
    +		$blocks = Blocks::factory($value);
    +
    +		$this->assertSame('<p><a rel="noreferrer">Test</a></p>', $blocks->toHtml());
    +	}
    +
     	public function testParsePageObject()
     	{
     		$expected = [
    
  • tests/Parsley/InlineTest.php+35 0 modified
    @@ -178,6 +178,41 @@ public function testParseNodeWithKnownMarksWithAttrDefaults()
     		$this->assertSame('<a href="https://getkirby.com" rel="test">Test</a>', $html);
     	}
     
    +	/**
    +	 * @covers ::parseNode
    +	 */
    +	public function testSkipsDangerousHref()
    +	{
    +		$dom  = new Dom('<p><a href="javascript://x%0Aalert(1)">Test</a></p>');
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href', 'rel'],
    +				'defaults' => [
    +					'rel' => 'noreferrer'
    +				]
    +			],
    +		]);
    +
    +		$this->assertSame('<a rel="noreferrer">Test</a>', $html);
    +	}
    +
    +	/**
    +	 * @covers ::parseNode
    +	 */
    +	public function testSkipsObfuscatedHref()
    +	{
    +		$dom  = new Dom("<p><a href=\"java\tscript://x%0Aalert(1)\">Test</a></p>");
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href'],
    +			],
    +		]);
    +
    +		$this->assertSame('<a>Test</a>', $html);
    +	}
    +
     	/**
     	 * @covers ::parseNode
     	 */
    
  • tests/Parsley/Schema/BlocksTest.php+23 0 modified
    @@ -435,6 +435,29 @@ public function testImgWithLink()
     		return $this->assertSame($expected, $this->schema->img($element));
     	}
     
    +	public function testImgDangerousLink()
    +	{
    +		$html = <<<HTML
    +            <a href="javascript://x%0Aalert(1)">
    +                <img src="https://getkirby.com/image.jpg" alt="Test">
    +            </a>
    +        HTML;
    +
    +		$element  = $this->element($html, '//img');
    +		$expected = [
    +			'content' => [
    +				'alt'      => 'Test',
    +				'caption'  => null,
    +				'link'     => null,
    +				'location' => 'web',
    +				'src'      => 'https://getkirby.com/image.jpg'
    +			],
    +			'type' => 'image',
    +		];
    +
    +		return $this->assertSame($expected, $this->schema->img($element));
    +	}
    +
     	public function testImgWithCaption()
     	{
     		$html = <<<HTML
    
42241631add2

fix: Filter dangerous hrefs in Parsley HTML import (#56)

https://github.com/getkirby/kirbyAhmet BoraMay 13, 2026Fixed in 5.4.1via llm-release-walk
5 files changed · +73 1
  • src/Parsley/Inline.php+8 1 modified
    @@ -6,6 +6,7 @@
     use DOMNode;
     use DOMNodeList;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Toolkit\Html;
     
     /**
    @@ -62,10 +63,16 @@ public static function parseAttrs(
     		$defaults = $mark['defaults'] ?? [];
     
     		foreach ($mark['attrs'] ?? [] as $attr) {
    -			$attrs[$attr] = match ($node->hasAttribute($attr)) {
    +			$value = match ($node->hasAttribute($attr)) {
     				true    => $node->getAttribute($attr),
     				default => $defaults[$attr] ?? null
     			};
    +
    +			if ($attr === 'href' && Url::hasDangerousScheme($value) === true) {
    +				continue;
    +			}
    +
    +			$attrs[$attr] = $value;
     		}
     
     		return $attrs;
    
  • src/Parsley/Schema/Blocks.php+5 0 modified
    @@ -4,6 +4,7 @@
     
     use DOMElement;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Parsley\Element;
     use Kirby\Toolkit\Str;
     
    @@ -155,6 +156,10 @@ public function img(Element $node): array
     		$figcaption = $node->find('ancestor::figure[1]//figcaption');
     		$caption    = $figcaption?->innerHTML($this->marks());
     
    +		if (Url::hasDangerousScheme($link) === true) {
    +			$link = null;
    +		}
    +
     		// avoid parsing the caption twice
     		$figcaption?->remove();
     
    
  • tests/Cms/Blocks/BlocksTest.php+8 0 modified
    @@ -146,6 +146,14 @@ public function testParseString(): void
     		$this->assertSame($expected, $result);
     	}
     
    +	public function testParseSkipsDangerousHref(): void
    +	{
    +		$value  = Blocks::parse('<a href="javascript://x%0Aalert(1)">Test</a>');
    +		$blocks = Blocks::factory($value);
    +
    +		$this->assertSame('<p><a rel="noreferrer">Test</a></p>', $blocks->toHtml());
    +	}
    +
     	public function testParsePageObject(): void
     	{
     		$expected = [
    
  • tests/Parsley/InlineTest.php+29 0 modified
    @@ -145,6 +145,35 @@ public function testParseNodeWithKnownMarksWithAttrDefaults(): void
     		$this->assertSame('<a href="https://getkirby.com" rel="test">Test</a>', $html);
     	}
     
    +	public function testSkipsDangerousHref(): void
    +	{
    +		$dom  = new Dom('<p><a href="javascript://x%0Aalert(1)">Test</a></p>');
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href', 'rel'],
    +				'defaults' => [
    +					'rel' => 'noreferrer'
    +				]
    +			],
    +		]);
    +
    +		$this->assertSame('<a rel="noreferrer">Test</a>', $html);
    +	}
    +
    +	public function testSkipsObfuscatedHref(): void
    +	{
    +		$dom  = new Dom("<p><a href=\"java\tscript://x%0Aalert(1)\">Test</a></p>");
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href'],
    +			],
    +		]);
    +
    +		$this->assertSame('<a>Test</a>', $html);
    +	}
    +
     	public function testParseNodeWithUnkownMarks(): void
     	{
     		$dom     = new Dom('<p><b>Test</b> <i>Test</i></p>');
    
  • tests/Parsley/Schema/BlocksTest.php+23 0 modified
    @@ -419,6 +419,29 @@ public function testImgWithLink(): void
     		$this->assertSame($expected, $this->schema->img($element));
     	}
     
    +	public function testImgDangerousLink(): void
    +	{
    +		$html = <<<HTML
    +			<a href="javascript://x%0Aalert(1)">
    +				<img src="https://getkirby.com/image.jpg" alt="Test">
    +			</a>
    +			HTML;
    +
    +		$element  = $this->element($html, '//img');
    +		$expected = [
    +			'content' => [
    +				'alt'      => 'Test',
    +				'caption'  => null,
    +				'link'     => null,
    +				'location' => 'web',
    +				'src'      => 'https://getkirby.com/image.jpg'
    +			],
    +			'type' => 'image',
    +		];
    +
    +		$this->assertSame($expected, $this->schema->img($element));
    +	}
    +
     	public function testImgWithCaption(): void
     	{
     		$html = <<<HTML
    
1e40f89dcc32

fix: Apply block attribute on even more risky methods

https://github.com/getkirby/kirbyBastian AllgeierMay 13, 2026Fixed in 4.9.1via llm-release-walk
8 files changed · +23 0
  • src/Cms/Block.php+1 0 modified
    @@ -219,6 +219,7 @@ public function toArray(): array
     	 * object. This can be used further
     	 * with all available field methods
     	 */
    +	#[BlockCollectionAccess]
     	public function toField(): Field
     	{
     		return new Field($this->parent(), $this->id(), $this->toHtml());
    
  • src/Cms/HasMethods.php+4 0 modified
    @@ -4,6 +4,7 @@
     
     use Closure;
     use Kirby\Exception\BadMethodCallException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * HasMethods
    @@ -28,6 +29,7 @@ trait HasMethods
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	public function callMethod(string $method, array $args = []): mixed
     	{
     		$closure = $this->getMethod($method);
    @@ -43,6 +45,7 @@ public function callMethod(string $method, array $args = []): mixed
     	 * Checks if the object has a registered method
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function hasMethod(string $method): bool
     	{
     		return $this->getMethod($method) !== null;
    @@ -53,6 +56,7 @@ public function hasMethod(string $method): bool
     	 * the current class or from a parent class ordered by
     	 * inheritance order (top to bottom)
     	 */
    +	#[BlockCollectionAccess]
     	public function getMethod(string $method): Closure|null
     	{
     		if (isset(static::$methods[$method]) === true) {
    
  • src/Cms/ModelWithContent.php+11 0 modified
    @@ -11,6 +11,7 @@
     use Kirby\Exception\NotFoundException;
     use Kirby\Form\Form;
     use Kirby\Panel\Model;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Kirby\Uuid\Identifiable;
     use Kirby\Uuid\Uuid;
    @@ -106,6 +107,7 @@ public function blueprints(string|null $inSection = null): array
     	 *
     	 * @todo eventually refactor without need of propertyData
     	 */
    +	#[BlockCollectionAccess]
     	public function clone(array $props = []): static
     	{
     		return new static(array_replace_recursive($this->propertyData, $props));
    @@ -175,6 +177,7 @@ public function content(string|null $languageCode = null): Content
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(
     		string|null $languageCode = null,
     		bool $force = false
    @@ -196,6 +199,7 @@ public function contentFile(
     	 * @todo Remove in v5
     	 * @codeCoverageIgnore
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFiles(): array
     	{
     		Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file');
    @@ -226,6 +230,7 @@ public function contentFileData(
     	 * @todo Remove in v5
     	 * @codeCoverageIgnore
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFileDirectory(): string|null
     	{
     		Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
    @@ -312,6 +317,7 @@ protected function convertTo(string $blueprint): static
     	/**
     	 * Decrement a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function decrement(
     		string $field,
     		int $by = 1,
    @@ -344,6 +350,7 @@ public function errors(): array
     	 * Creates a clone and fetches all
     	 * lazy-loaded getters to get a full copy
     	 */
    +	#[BlockCollectionAccess]
     	public function hardcopy(): static
     	{
     		$clone = $this->clone();
    @@ -368,6 +375,7 @@ public function id(): string|null
     	/**
     	 * Increment a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function increment(
     		string $field,
     		int $by = 1,
    @@ -515,6 +523,7 @@ abstract public function root(): string|null;
     	 * Stores the content on disk
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function save(
     		array|null $data = null,
     		string|null $languageCode = null,
    @@ -765,6 +774,7 @@ public function translations(): Collection
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    @@ -811,6 +821,7 @@ public function uuid(): Uuid|null
     	 * to store the given data on disk or anywhere else
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function writeContent(array $data, string|null $languageCode = null): bool
     	{
     		$data = $this->contentFileData($data, $languageCode);
    
  • src/Cms/Page.php+1 0 modified
    @@ -1092,6 +1092,7 @@ protected function rules(): PageRules
     	/**
     	 * Search all pages within the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function search(string|null $query = null, string|array $params = []): Pages
     	{
     		return $this->index()->search($query, $params);
    
  • src/Cms/Role.php+2 0 modified
    @@ -5,6 +5,7 @@
     use Exception;
     use Kirby\Data\Data;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     
     /**
    @@ -144,6 +145,7 @@ public function isNobody(): bool
     		return $this->name() === 'nobody';
     	}
     
    +	#[BlockCollectionAccess]
     	public static function load(string $file, array $inject = []): static
     	{
     		$data = Data::read($file);
    
  • src/Cms/Site.php+1 0 modified
    @@ -418,6 +418,7 @@ protected function rules(): SiteRules
     	/**
     	 * Search all pages in the site
     	 */
    +	#[BlockCollectionAccess]
     	public function search(string|null $query = null, string|array $params = []): Pages
     	{
     		return $this->index()->search($query, $params);
    
  • src/Cms/Translation.php+2 0 modified
    @@ -4,6 +4,7 @@
     
     use Exception;
     use Kirby\Data\Data;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -105,6 +106,7 @@ public function id(): string
     	 * Loads the translation from the
     	 * json file in Kirby's translations folder
     	 */
    +	#[BlockCollectionAccess]
     	public static function load(
     		string $code,
     		string $root,
    
  • src/Query/Segment.php+1 0 modified
    @@ -37,6 +37,7 @@ public function __construct(
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	public static function error(mixed $data, string $name, string $label): void
     	{
     		$type = strtolower(gettype($data));
    
62f4657f42de

fix: Apply block attribute on even more risky methods

https://github.com/getkirby/kirbyBastian AllgeierMay 13, 2026Fixed in 5.4.1via llm-release-walk
9 files changed · +22 0
  • src/Cms/Block.php+1 0 modified
    @@ -197,6 +197,7 @@ public function toArray(): array
     	 * object. This can be used further
     	 * with all available field methods
     	 */
    +	#[BlockCollectionAccess]
     	public function toField(): Field
     	{
     		return new Field($this->parent(), $this->id(), $this->toHtml());
    
  • src/Cms/HasMethods.php+4 0 modified
    @@ -4,6 +4,7 @@
     
     use Closure;
     use Kirby\Exception\BadMethodCallException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * HasMethods
    @@ -27,6 +28,7 @@ trait HasMethods
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	protected function callMethod(string $method, array $args = []): mixed
     	{
     		$closure = $this->getMethod($method);
    @@ -43,6 +45,7 @@ protected function callMethod(string $method, array $args = []): mixed
     	/**
     	 * Checks if the object has a registered custom method
     	 */
    +	#[BlockCollectionAccess]
     	public function hasMethod(string $method): bool
     	{
     		return $this->getMethod($method) !== null;
    @@ -53,6 +56,7 @@ public function hasMethod(string $method): bool
     	 * the current class or from a parent class ordered by
     	 * inheritance order (top to bottom)
     	 */
    +	#[BlockCollectionAccess]
     	public function getMethod(string $method): Closure|null
     	{
     		if (isset(static::$methods[$method]) === true) {
    
  • src/Cms/ModelWithContent.php+9 0 modified
    @@ -17,6 +17,7 @@
     use Kirby\Form\Fields;
     use Kirby\Form\Form;
     use Kirby\Panel\Model;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Kirby\Uuid\Identifiable;
     use Kirby\Uuid\Uuid;
    @@ -109,6 +110,7 @@ public function blueprints(string|null $inSection = null): array
     	 * @since 5.0.0
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function changeStorage(Storage|string $toStorage, bool $copy = false): static
     	{
     		if (is_string($toStorage) === true) {
    @@ -132,6 +134,7 @@ public function changeStorage(Storage|string $toStorage, bool $copy = false): st
     	 *
     	 * @todo eventually refactor without need of propertyData
     	 */
    +	#[BlockCollectionAccess]
     	public function clone(array $props = []): static
     	{
     		$props = array_replace_recursive($this->propertyData, $props);
    @@ -268,6 +271,7 @@ public function createDefaultContent(): array
     	/**
     	 * Decrement a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function decrement(
     		string $field,
     		int $by = 1,
    @@ -300,6 +304,7 @@ public function errors(): array
     	 * Creates a clone and fetches all
     	 * lazy-loaded getters to get a full copy
     	 */
    +	#[BlockCollectionAccess]
     	public function hardcopy(): static
     	{
     		$clone = $this->clone();
    @@ -324,6 +329,7 @@ public function id(): string|null
     	/**
     	 * Increment a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function increment(
     		string $field,
     		int $by = 1,
    @@ -442,6 +448,7 @@ abstract public function root(): string|null;
     	 * Low-level method to save the model with the given data.
     	 * Consider using `::update()` instead.
     	 */
    +	#[BlockCollectionAccess]
     	public function save(
     		array|null $data = null,
     		string|null $languageCode = null,
    @@ -655,6 +662,7 @@ public function translations(): Translations
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    @@ -723,6 +731,7 @@ public function versions(): Versions
     	 * @internal
     	 * @deprecated 5.0.0 Use `->version()->save()` instead
     	 */
    +	#[BlockCollectionAccess]
     	public function writeContent(array $data, string|null $languageCode = null): bool
     	{
     		Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore
    
  • src/Cms/Page.php+1 0 modified
    @@ -1086,6 +1086,7 @@ protected function rules(): PageRules
     	/**
     	 * Search all pages within the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function search(string|null $query = null, string|array $params = []): Pages
     	{
     		return $this->index()->search($query, $params);
    
  • src/Cms/Role.php+2 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Data\Data;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     use Kirby\Toolkit\Str;
     use Stringable;
    @@ -148,6 +149,7 @@ public function isNobody(): bool
     		return $this->name() === 'nobody';
     	}
     
    +	#[BlockCollectionAccess]
     	public static function load(string $file, array $inject = []): static
     	{
     		$data = [
    
  • src/Cms/Site.php+1 0 modified
    @@ -412,6 +412,7 @@ protected function rules(): SiteRules
     	/**
     	 * Search all pages in the site
     	 */
    +	#[BlockCollectionAccess]
     	public function search(
     		string|null $query = null,
     		string|array $params = []
    
  • src/Cms/Translation.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Kirby\Data\Data;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -105,6 +106,7 @@ public function id(): string
     	 * Loads the translation from the
     	 * json file in Kirby's translations folder
     	 */
    +	#[BlockCollectionAccess]
     	public static function load(
     		string $code,
     		string $root,
    
  • src/Content/Version.php+1 0 modified
    @@ -631,6 +631,7 @@ public function update(
     	 * Returns the preview URL with authentication for drafts and versions
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function url(): string|null
     	{
     		if (
    
  • src/Query/Segment.php+1 0 modified
    @@ -39,6 +39,7 @@ public function __construct(
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	public static function error(
     		mixed $data,
     		string $name,
    
f267929999cc

fix: Apply block attribute on more risky methods

https://github.com/getkirby/kirbyBastian AllgeierMay 12, 2026Fixed in 4.9.1via llm-release-walk
13 files changed · +34 0
  • src/Cms/Block.php+3 0 modified
    @@ -5,6 +5,7 @@
     use Kirby\Content\Content;
     use Kirby\Content\Field;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -100,6 +101,7 @@ public function content(): Content
     	/**
     	 * Controller for the block snippet
     	 */
    +	#[BlockCollectionAccess]
     	public function controller(): array
     	{
     		return [
    @@ -225,6 +227,7 @@ public function toField(): Field
     	/**
     	 * Converts the block to HTML
     	 */
    +	#[BlockCollectionAccess]
     	public function toHtml(): string
     	{
     		try {
    
  • src/Cms/FileActions.php+1 0 modified
    @@ -226,6 +226,7 @@ public function copy(Page $page): static
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props, bool $move = false): File
     	{
     		// Prevent injecting blueprint as this always must be derived from
    
  • src/Cms/FileModifications.php+11 0 modified
    @@ -5,6 +5,7 @@
     use Kirby\Content\Field;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Filesystem\Asset;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * Trait for image resizing, blurring etc.
    @@ -20,6 +21,7 @@ trait FileModifications
     	/**
     	 * Blurs the image by the given amount of pixels
     	 */
    +	#[BlockCollectionAccess]
     	public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	{
     		return $this->thumb(['blur' => $pixels]);
    @@ -28,6 +30,7 @@ public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	/**
     	 * Converts the image to black and white
     	 */
    +	#[BlockCollectionAccess]
     	public function bw(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -36,6 +39,7 @@ public function bw(): FileVersion|File|Asset
     	/**
     	 * Crops the image by the given width and height
     	 */
    +	#[BlockCollectionAccess]
     	public function crop(
     		int $width,
     		int|null $height = null,
    @@ -66,6 +70,7 @@ public function crop(
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function grayscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -74,6 +79,7 @@ public function grayscale(): FileVersion|File|Asset
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function greyscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -82,6 +88,7 @@ public function greyscale(): FileVersion|File|Asset
     	/**
     	 * Sets the JPEG compression quality
     	 */
    +	#[BlockCollectionAccess]
     	public function quality(int $quality): FileVersion|File|Asset
     	{
     		return $this->thumb(['quality' => $quality]);
    @@ -93,6 +100,7 @@ public function quality(int $quality): FileVersion|File|Asset
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function resize(
     		int|null $width = null,
     		int|null $height = null,
    @@ -108,6 +116,7 @@ public function resize(
     	/**
     	 * Sharpens the image
     	 */
    +	#[BlockCollectionAccess]
     	public function sharpen(int $amount = 50): FileVersion|File|Asset
     	{
     		return $this->thumb(['sharpen' => $amount]);
    @@ -119,6 +128,7 @@ public function sharpen(int $amount = 50): FileVersion|File|Asset
     	 * also be set up in the config with the thumbs.srcsets option.
     	 * @since 3.1.0
     	 */
    +	#[BlockCollectionAccess]
     	public function srcset(array|string|null $sizes = null): string|null
     	{
     		if (empty($sizes) === true) {
    @@ -168,6 +178,7 @@ public function srcset(array|string|null $sizes = null): string|null
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function thumb(
     		array|string|null $options = null
     	): FileVersion|File|Asset {
    
  • src/Cms/File.php+1 0 modified
    @@ -606,6 +606,7 @@ public function templateSiblings(bool $self = true): Files
     	 * by injecting the information from
     	 * the asset.
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return array_merge(parent::toArray(), $this->asset()->toArray(), [
    
  • src/Cms/Language.php+6 0 modified
    @@ -8,6 +8,7 @@
     use Kirby\Exception\LogicException;
     use Kirby\Exception\PermissionException;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Locale;
     use Kirby\Toolkit\Str;
     use Throwable;
    @@ -144,6 +145,7 @@ public function code(): string
     	 * Creates a new language object
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): static
     	{
     		$kirby = App::instance();
    @@ -211,6 +213,7 @@ public static function create(array $props): static
     	 *
     	 * @throws \Kirby\Exception\Exception
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		$kirby = App::instance();
    @@ -392,6 +395,7 @@ public function pattern(): string
     	/**
     	 * Returns the absolute path to the language file
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return App::instance()->root('languages') . '/' . $this->code() . '.php';
    @@ -424,6 +428,7 @@ public function rules(): array
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function save(): static
     	{
     		try {
    @@ -514,6 +519,7 @@ public function url(): string
     	 * Update language properties and save them
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function update(array|null $props = null): static
     	{
     		$kirby = App::instance();
    
  • src/Cms/PageActions.php+1 0 modified
    @@ -503,6 +503,7 @@ public function copy(array $options = []): static
     	/**
     	 * Creates and stores a new page
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): Page
     	{
     		// Prevent injecting blueprint as this always must be derived from
    
  • src/Cms/Page.php+1 0 modified
    @@ -1208,6 +1208,7 @@ public function title(): Field
     	 * Converts the most important
     	 * properties to array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return array_merge(parent::toArray(), [
    
  • src/Cms/UserActions.php+1 0 modified
    @@ -222,6 +222,7 @@ protected function commit(
     	/**
     	 * Creates a new User from the given props and returns a new User object
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array|null $props = null): User
     	{
     		// Prevent injecting blueprint as this always must be derived from
    
  • src/Cms/User.php+1 0 modified
    @@ -736,6 +736,7 @@ protected function siblingsCollection(): Users
     	 * Converts the most important user properties
     	 * to an array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return array_merge(parent::toArray(), [
    
  • src/Content/ContentTranslation.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Content;
     
     use Kirby\Cms\ModelWithContent;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * Each page, file or site can have multiple
    @@ -83,6 +84,7 @@ public function content(): array
     	/**
     	 * Absolute path to the translation content file
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(): string
     	{
     		// temporary compatibility change (TODO: take this from the parent `ModelVersion` object)
    
  • src/Form/FieldClass.php+2 0 modified
    @@ -8,6 +8,7 @@
     use Kirby\Cms\HasSiblings;
     use Kirby\Cms\ModelWithContent;
     use Kirby\Data\Data;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     use Kirby\Toolkit\Str;
     use Throwable;
    @@ -161,6 +162,7 @@ public function errors(): array
     	/**
     	 * Setter for the value
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value = null): void
     	{
     		$this->value = $value;
    
  • src/Query/Argument.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Query;
     
     use Closure;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -98,6 +99,7 @@ public static function factory(string $argument): static
     	 * Return the argument value and
     	 * resolves nested objects to scaler types
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(array|object $data = []): mixed
     	{
     		// don't resolve the Closure immediately, instead
    
  • src/Query/Segment.php+2 0 modified
    @@ -5,6 +5,7 @@
     use Closure;
     use Kirby\Exception\BadMethodCallException;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -80,6 +81,7 @@ public static function factory(
     	 *
     	 * @param mixed $base Current value of the query chain
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(mixed $base = null, array|object $data = []): mixed
     	{
     		// resolve arguments to array
    
d9d657deb072

fix: Apply hasDangerousScheme check in Html::link instead of Html::a

https://github.com/getkirby/kirbyBastian AllgeierMay 12, 2026Fixed in 4.9.1via llm-release-walk
2 files changed · +46 21
  • src/Toolkit/Html.php+4 4 modified
    @@ -120,10 +120,6 @@ public static function a(string $href, $text = null, array $attr = []): string
     			return static::tel(substr($href, 4), $text, $attr);
     		}
     
    -		if (Url::hasDangerousScheme($href) === true) {
    -			$href = '';
    -		}
    -
     		return static::link($href, $text, $attr);
     	}
     
    @@ -354,6 +350,10 @@ public static function link(
     		string|array|null $text = null,
     		array $attr = []
     	): string {
    +		if (Url::hasDangerousScheme($href) === true) {
    +			$href = '';
    +		}
    +
     		$attr = array_merge(['href' => $href], $attr);
     
     		if (empty($text) === true) {
    
  • tests/Toolkit/HtmlTest.php+42 17 modified
    @@ -109,62 +109,87 @@ public function testAWithTargetAndRel()
     	 * @covers ::a
     	 */
     	public function testAWithDangerousSchemes(): void
    +	{
    +		// dangerous schemes are blocked via Html::link delegation
    +		$html = Html::a('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +	}
    +
    +	/**
    +	 * @covers ::link
    +	 */
    +	public function testLink(): void
    +	{
    +		$html = Html::link('https://getkirby.com');
    +		$expected = '<a href="https://getkirby.com">getkirby.com</a>';
    +		$this->assertSame($expected, $html);
    +
    +		$html = Html::link('https://getkirby.com', 'Kirby');
    +		$expected = '<a href="https://getkirby.com">Kirby</a>';
    +		$this->assertSame($expected, $html);
    +	}
    +
    +	/**
    +	 * @covers ::link
    +	 */
    +	public function testLinkWithDangerousSchemes(): void
     	{
     		// javascript://
    -		$html = Html::a('javascript://comment%0Aalert(1)', 'click');
    +		$html = Html::link('javascript://comment%0Aalert(1)', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     		$this->assertStringContainsString('href=""', $html);
     
     		// bare javascript: (no //)
    -		$html = Html::a('javascript:alert(1)', 'click');
    +		$html = Html::link('javascript:alert(1)', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
     		// other dangerous schemes
    -		$html = Html::a('vbscript://x', 'click');
    +		$html = Html::link('vbscript://x', 'click');
     		$this->assertStringNotContainsString('vbscript:', $html);
     
    -		$html = Html::a('data://text/html;base64,PHN2Zz4=', 'click');
    +		$html = Html::link('data://text/html;base64,PHN2Zz4=', 'click');
     		$this->assertStringNotContainsString('data:', $html);
     
    -		$html = Html::a('livescript://x', 'click');
    +		$html = Html::link('livescript://x', 'click');
     		$this->assertStringNotContainsString('livescript:', $html);
     
    -		$html = Html::a('mocha://x', 'click');
    +		$html = Html::link('mocha://x', 'click');
     		$this->assertStringNotContainsString('mocha:', $html);
     
    -		$html = Html::a('jar://x', 'click');
    +		$html = Html::link('jar://x', 'click');
     		$this->assertStringNotContainsString('jar:', $html);
     
     		// whitespace/tab/newline bypass attempts
    -		$html = Html::a(' javascript://x', 'click');
    +		$html = Html::link(' javascript://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("\tjavascript://x", 'click');
    +		$html = Html::link("\tjavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("\njavascript://x", 'click');
    +		$html = Html::link("\njavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a('java script://x', 'click');
    +		$html = Html::link('java script://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a('java script://x', 'click');
    +		$html = Html::link('java script://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("java\tscript://x", 'click');
    +		$html = Html::link("java\tscript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("javasc\nript://x", 'click');
    +		$html = Html::link("javasc\nript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
     		// safe schemes must still work
    -		$html = Html::a('https://getkirby.com', 'Kirby');
    +		$html = Html::link('https://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="https://getkirby.com"', $html);
     
    -		$html = Html::a('custom://getkirby.com', 'Kirby');
    +		$html = Html::link('custom://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="custom://getkirby.com"', $html);
     
    -		$html = Html::a('/relative/path', 'link');
    +		$html = Html::link('/relative/path', 'link');
     		$this->assertStringContainsString('href="/relative/path"', $html);
     	}
     
    
dfb9b4fc36c5

fix: Move dangerous scheme check into Html::link method

https://github.com/getkirby/kirbyBastian AllgeierMay 12, 2026Fixed in 5.4.1via llm-release-walk
2 files changed · +40 21
  • src/Toolkit/Html.php+4 4 modified
    @@ -119,10 +119,6 @@ public static function a(
     			return static::tel(substr($href, 4), $text, $attr);
     		}
     
    -		if (Url::hasDangerousScheme($href) === true) {
    -			$href = '';
    -		}
    -
     		return static::link($href, $text, $attr);
     	}
     
    @@ -364,6 +360,10 @@ public static function link(
     		string|array|null $text = null,
     		array $attr = []
     	): string {
    +		if (Url::hasDangerousScheme($href) === true) {
    +			$href = '';
    +		}
    +
     		$attr = ['href' => $href, ...$attr];
     
     		if (empty($text) === true) {
    
  • tests/Toolkit/HtmlTest.php+36 17 modified
    @@ -83,62 +83,81 @@ public function testAWithTargetAndRel(): void
     	}
     
     	public function testAWithDangerousSchemes(): void
    +	{
    +		// dangerous schemes are blocked via Html::link delegation
    +		$html = Html::a('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +	}
    +
    +	public function testLink(): void
    +	{
    +		$html = Html::link('https://getkirby.com');
    +		$expected = '<a href="https://getkirby.com">getkirby.com</a>';
    +		$this->assertSame($expected, $html);
    +
    +		$html = Html::link('https://getkirby.com', 'Kirby');
    +		$expected = '<a href="https://getkirby.com">Kirby</a>';
    +		$this->assertSame($expected, $html);
    +	}
    +
    +	public function testLinkWithDangerousSchemes(): void
     	{
     		// javascript://
    -		$html = Html::a('javascript://comment%0Aalert(1)', 'click');
    +		$html = Html::link('javascript://comment%0Aalert(1)', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     		$this->assertStringContainsString('href=""', $html);
     
     		// bare javascript: (no //)
    -		$html = Html::a('javascript:alert(1)', 'click');
    +		$html = Html::link('javascript:alert(1)', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
     		// other dangerous schemes
    -		$html = Html::a('vbscript://x', 'click');
    +		$html = Html::link('vbscript://x', 'click');
     		$this->assertStringNotContainsString('vbscript:', $html);
     
    -		$html = Html::a('data://text/html;base64,PHN2Zz4=', 'click');
    +		$html = Html::link('data://text/html;base64,PHN2Zz4=', 'click');
     		$this->assertStringNotContainsString('data:', $html);
     
    -		$html = Html::a('livescript://x', 'click');
    +		$html = Html::link('livescript://x', 'click');
     		$this->assertStringNotContainsString('livescript:', $html);
     
    -		$html = Html::a('mocha://x', 'click');
    +		$html = Html::link('mocha://x', 'click');
     		$this->assertStringNotContainsString('mocha:', $html);
     
    -		$html = Html::a('jar://x', 'click');
    +		$html = Html::link('jar://x', 'click');
     		$this->assertStringNotContainsString('jar:', $html);
     
     		// whitespace/tab/newline bypass attempts
    -		$html = Html::a(' javascript://x', 'click');
    +		$html = Html::link(' javascript://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("\tjavascript://x", 'click');
    +		$html = Html::link("\tjavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("\njavascript://x", 'click');
    +		$html = Html::link("\njavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a('java script://x', 'click');
    +		$html = Html::link('java script://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a('java script://x', 'click');
    +		$html = Html::link('java script://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("java\tscript://x", 'click');
    +		$html = Html::link("java\tscript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    -		$html = Html::a("javasc\nript://x", 'click');
    +		$html = Html::link("javasc\nript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
     		// safe schemes must still work
    -		$html = Html::a('https://getkirby.com', 'Kirby');
    +		$html = Html::link('https://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="https://getkirby.com"', $html);
     
    -		$html = Html::a('custom://getkirby.com', 'Kirby');
    +		$html = Html::link('custom://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="custom://getkirby.com"', $html);
     
    -		$html = Html::a('/relative/path', 'link');
    +		$html = Html::link('/relative/path', 'link');
     		$this->assertStringContainsString('href="/relative/path"', $html);
     	}
     
    
78aed957449a

fix: Apply block attribute on more risky methods

https://github.com/getkirby/kirbyBastian AllgeierMay 11, 2026Fixed in 5.4.1via llm-release-walk
17 files changed · +62 0
  • src/Cms/Block.php+3 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Content\Content;
     use Kirby\Content\Field;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Toolkit\Str;
     use Stringable;
    @@ -101,6 +102,7 @@ public function content(): Content
     	/**
     	 * Controller for the block snippet
     	 */
    +	#[BlockCollectionAccess]
     	public function controller(): array
     	{
     		return [
    @@ -203,6 +205,7 @@ public function toField(): Field
     	/**
     	 * Converts the block to HTML
     	 */
    +	#[BlockCollectionAccess]
     	public function toHtml(): string
     	{
     		try {
    
  • src/Cms/FileActions.php+1 0 modified
    @@ -212,6 +212,7 @@ public function copy(Page $page): static
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props, bool $move = false): static
     	{
     		$props = static::normalizeProps($props);
    
  • src/Cms/FileModifications.php+11 0 modified
    @@ -5,6 +5,7 @@
     use Kirby\Content\Field;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Filesystem\Asset;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * Trait for image resizing, blurring etc.
    @@ -20,6 +21,7 @@ trait FileModifications
     	/**
     	 * Blurs the image by the given amount of pixels
     	 */
    +	#[BlockCollectionAccess]
     	public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	{
     		return $this->thumb(['blur' => $pixels]);
    @@ -28,6 +30,7 @@ public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	/**
     	 * Converts the image to black and white
     	 */
    +	#[BlockCollectionAccess]
     	public function bw(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -36,6 +39,7 @@ public function bw(): FileVersion|File|Asset
     	/**
     	 * Crops the image by the given width and height
     	 */
    +	#[BlockCollectionAccess]
     	public function crop(
     		int $width,
     		int|null $height = null,
    @@ -66,6 +70,7 @@ public function crop(
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function grayscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -74,6 +79,7 @@ public function grayscale(): FileVersion|File|Asset
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function greyscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -82,6 +88,7 @@ public function greyscale(): FileVersion|File|Asset
     	/**
     	 * Sets the JPEG compression quality
     	 */
    +	#[BlockCollectionAccess]
     	public function quality(int $quality): FileVersion|File|Asset
     	{
     		return $this->thumb(['quality' => $quality]);
    @@ -93,6 +100,7 @@ public function quality(int $quality): FileVersion|File|Asset
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function resize(
     		int|null $width = null,
     		int|null $height = null,
    @@ -108,6 +116,7 @@ public function resize(
     	/**
     	 * Sharpens the image
     	 */
    +	#[BlockCollectionAccess]
     	public function sharpen(int $amount = 50): FileVersion|File|Asset
     	{
     		return $this->thumb(['sharpen' => $amount]);
    @@ -119,6 +128,7 @@ public function sharpen(int $amount = 50): FileVersion|File|Asset
     	 * also be set up in the config with the thumbs.srcsets option.
     	 * @since 3.1.0
     	 */
    +	#[BlockCollectionAccess]
     	public function srcset(array|string|null $sizes = null): string|null
     	{
     		if (empty($sizes) === true) {
    @@ -168,6 +178,7 @@ public function srcset(array|string|null $sizes = null): string|null
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function thumb(
     		array|string|null $options = null
     	): FileVersion|File|Asset {
    
  • src/Cms/File.php+1 0 modified
    @@ -605,6 +605,7 @@ public function templateSiblings(bool $self = true): Files
     	 * by injecting the information from
     	 * the asset.
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Cms/Language.php+6 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Data\Data;
     use Kirby\Exception\Exception;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Exception\NotFoundException;
     use Kirby\Filesystem\F;
    @@ -153,6 +154,7 @@ public function code(): string
     	/**
     	 * Creates a new language object
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): static
     	{
     		$kirby         = App::instance();
    @@ -216,6 +218,7 @@ public static function create(array $props): static
     	 *
     	 * @throws \Kirby\Exception\Exception
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		$kirby = App::instance();
    @@ -456,6 +459,7 @@ public function permissions(): LanguagePermissions
     	/**
     	 * Returns the absolute path to the language file
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return App::instance()->root('languages') . '/' . $this->code() . '.php';
    @@ -489,6 +493,7 @@ public function rules(): array
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function save(): static
     	{
     		$existingData = Data::read($this->root(), fail: false);
    @@ -587,6 +592,7 @@ public function url(): string
     	/**
     	 * Update language properties and save them
     	 */
    +	#[BlockCollectionAccess]
     	public function update(array|null $props = null): static
     	{
     		$kirby = App::instance();
    
  • src/Cms/PageActions.php+1 0 modified
    @@ -444,6 +444,7 @@ public function copy(array $options = []): static
     	/**
     	 * Creates and stores a new page
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): Page
     	{
     		$props = self::normalizeProps($props);
    
  • src/Cms/Page.php+1 0 modified
    @@ -1202,6 +1202,7 @@ public function title(): Field
     	 * Converts the most important
     	 * properties to array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Cms/UserActions.php+1 0 modified
    @@ -173,6 +173,7 @@ protected function commit(
     	/**
     	 * Creates a new User from the given props and returns a new User object
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): User
     	{
     		$input = $props;
    
  • src/Cms/User.php+1 0 modified
    @@ -724,6 +724,7 @@ protected function siblingsCollection(): Users
     	 * Converts the most important user properties
     	 * to an array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Content/Translation.php+3 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Cms\Helpers;
     use Kirby\Cms\Language;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Cms\ModelWithContent;
     use Kirby\Exception\Exception;
     
    @@ -62,6 +63,7 @@ public function content(): array
     	 *
     	 * @deprecated 5.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(): string
     	{
     		Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods');
    @@ -74,6 +76,7 @@ public function contentFile(): string
     	 * @todo Needs to be refactored as soon as Version::create becomes static
     	 * 		 (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(
     		ModelWithContent $model,
     		Version $version,
    
  • src/Content/Version.php+13 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Cms\Language;
     use Kirby\Cms\Languages;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Cms\ModelWithContent;
     use Kirby\Cms\Page;
     use Kirby\Cms\Site;
    @@ -71,6 +72,7 @@ public function content(Language|string $language = 'default'): Content
     	 *
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(Language|string $language = 'default'): string
     	{
     		return $this->model->storage()->contentFile(
    @@ -94,6 +96,7 @@ protected function convertFieldNamesToLowerCase(array $fields): array
     	 *
     	 * @param array<string, string> $fields Content fields
     	 */
    +	#[BlockCollectionAccess]
     	public function create(
     		array $fields,
     		Language|string $language = 'default'
    @@ -121,6 +124,7 @@ public function create(
     	/**
     	 * Deletes a version for a specific language
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(Language|string $language = 'default'): void
     	{
     		if ($language === '*') {
    @@ -267,6 +271,7 @@ public function isValid(Language|string $language = 'default'): bool
     	/**
     	 * Returns the lock object for the version
     	 */
    +	#[BlockCollectionAccess]
     	public function lock(Language|string $language = 'default'): Lock
     	{
     		return Lock::for($this, $language);
    @@ -302,6 +307,7 @@ public function modified(
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function move(
     		Language|string $fromLanguage,
     		VersionId|null $toVersionId = null,
    @@ -396,6 +402,7 @@ protected function prepareFieldsAfterRead(array $fields, Language $language): ar
     	 * of draft and version previews
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function previewToken(): string
     	{
     		if ($this->model instanceof Site) {
    @@ -446,6 +453,7 @@ protected function previewTokenFromUrl(string $url): string
     	 * It will copy all fields over to the "latest" version and delete
     	 * this version afterwards.
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(Language|string $language = 'default'): void
     	{
     		$language = Language::ensure($language);
    @@ -482,6 +490,7 @@ public function publish(Language|string $language = 'default'): void
     	 *
     	 * @return array<string, string>|null
     	 */
    +	#[BlockCollectionAccess]
     	public function read(Language|string $language = 'default'): array|null
     	{
     		$language = Language::ensure($language);
    @@ -514,6 +523,7 @@ public function read(Language|string $language = 'default'): array|null
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function replace(
     		array $fields,
     		Language|string $language = 'default'
    @@ -537,6 +547,7 @@ public function replace(
     	/**
     	 * Convenience wrapper around ::create, ::replace and ::update.
     	 */
    +	#[BlockCollectionAccess]
     	public function save(
     		array $fields,
     		Language|string $language = 'default',
    @@ -571,6 +582,7 @@ public function sibling(VersionId|string $id): Version
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function touch(Language|string $language = 'default'): void
     	{
     		$language = Language::ensure($language);
    @@ -587,6 +599,7 @@ public function touch(Language|string $language = 'default'): void
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array $fields,
     		Language|string $language = 'default'
    
  • src/Form/FieldClass.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Form;
     
     use Kirby\Cms\HasSiblings;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     
     /**
    @@ -165,6 +166,7 @@ public function props(): array
     	 * @since 5.2.0
     	 * @todo Move to `Value` mixin once array-based fields are unsupported
     	 */
    +	#[BlockCollectionAccess]
     	public function reset(): static
     	{
     		$this->value = $this->emptyValue();
    
  • src/Form/Field.php+3 0 modified
    @@ -5,6 +5,7 @@
     use Closure;
     use Kirby\Cms\HasSiblings;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Component;
     use Kirby\Toolkit\I18n;
     
    @@ -284,6 +285,7 @@ public static function factory(
     	/**
     	 * Sets a new value for the field
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		// remember the current state to restore it afterwards
    @@ -316,6 +318,7 @@ public function fill(mixed $value): static
     	 *
     	 * @since 5.2.0
     	 */
    +	#[BlockCollectionAccess]
     	public function fillWithEmptyValue(): static
     	{
     		$this->value = $this->emptyValue();
    
  • src/Plugin/Asset.php+6 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Plugin;
     
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Stringable;
     
     /**
    @@ -38,6 +39,7 @@ public function filename(): string
     	/**
     	 * Create a unique media hash
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaHash(): string
     	{
     		return crc32($this->filename()) . '-' . $this->modified();
    @@ -46,6 +48,7 @@ public function mediaHash(): string
     	/**
     	 * Absolute path to the asset file in the media folder
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path();
    @@ -81,6 +84,7 @@ public function plugin(): Plugin
     	 * Publishes the asset file to the plugin's media folder
     	 * by creating a symlink
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): void
     	{
     		F::link($this->root(), $this->mediaRoot(), 'symlink');
    @@ -92,6 +96,7 @@ public function publish(): void
     	 * @deprecated 4.0.0
     	 * @codeCoverageIgnore
     	 */
    +	#[BlockCollectionAccess]
     	public function publishAt(string $path): void
     	{
     		F::link(
    @@ -101,6 +106,7 @@ public function publishAt(string $path): void
     		);
     	}
     
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root;
    
  • src/Plugin/Plugin.php+5 0 modified
    @@ -10,6 +10,7 @@
     use Kirby\Data\Data;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Kirby\Toolkit\V;
     use Throwable;
    @@ -189,6 +190,7 @@ public function license(): License
     	/**
     	 * Returns the path to the plugin's composer.json
     	 */
    +	#[BlockCollectionAccess]
     	public function manifest(): string
     	{
     		return $this->root() . '/composer.json';
    @@ -197,6 +199,7 @@ public function manifest(): string
     	/**
     	 * Returns the root where plugin assets are copied to
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->kirby()->root('media') . '/plugins/' . $this->name();
    @@ -237,6 +240,7 @@ public function prefix(): string
     	/**
     	 * Returns the root where the plugin files are stored
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root;
    @@ -245,6 +249,7 @@ public function root(): string
     	/**
     	 * Returns all available plugin metadata
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Query/Argument.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Query;
     
     use Closure;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -100,6 +101,7 @@ public static function factory(string $argument): static
     	 * Return the argument value and
     	 * resolves nested objects to scaler types
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(array|object $data = []): mixed
     	{
     		// don't resolve the Closure immediately, instead
    
  • src/Query/Segment.php+2 0 modified
    @@ -5,6 +5,7 @@
     use Closure;
     use Kirby\Exception\BadMethodCallException;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -85,6 +86,7 @@ public static function factory(
     	 *
     	 * @param mixed $base Current value of the query chain
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(mixed $base = null, array|object $data = []): mixed
     	{
     		// resolve arguments to array
    
0e3a814e651b

fix: More thorough whitespace handling in URL schemes

https://github.com/getkirby/kirbyLukas BestleMay 9, 2026Fixed in 4.9.1via llm-release-walk
3 files changed · +33 6
  • src/Http/Url.php+12 5 modified
    @@ -101,11 +101,18 @@ public static function index(array $props = []): string
     	 */
     	public static function hasDangerousScheme(string|null $url = null): bool
     	{
    -		return
    -			$url !== null &&
    -			// strip leading whitespace to prevent
    -			// tab/space-prefix bypass attempts
    -			preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data)\s*:!i', ltrim($url)) === 1;
    +		if ($url === null) {
    +			return false;
    +		}
    +
    +		// strip any weird characters to prevent bypass attempts,
    +		// keeping only the characters we test for below
    +		// (especially removes any whitespace that the browser would ignore
    +		// when the resulting URL is evaluated)
    +		$url = preg_replace('/[^a-z:]/i', '', $url);
    +
    +		// try to find a match from the blocklist case-insensitively
    +		return preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data):!i', $url) === 1;
     	}
     
     	/**
    
  • tests/Http/UrlTest.php+8 0 modified
    @@ -84,12 +84,20 @@ public function testHasDangerousScheme(): void
     		$this->assertTrue(Url::hasDangerousScheme('mocha://x'));
     		$this->assertTrue(Url::hasDangerousScheme('jar://x'));
     		$this->assertTrue(Url::hasDangerousScheme('JAVASCRIPT://x'));
    +
     		// whitespace prefix bypass attempts
     		$this->assertTrue(Url::hasDangerousScheme(' javascript://x'));
     		$this->assertTrue(Url::hasDangerousScheme("\tjavascript://x"));
     		$this->assertTrue(Url::hasDangerousScheme("\njavascript://x"));
     		$this->assertTrue(Url::hasDangerousScheme("\rjavascript://x"));
     
    +		// whitespace bypass in the middle of the string
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("java\tscript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javas\ncript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javasc\rript://x"));
    +
     		$this->assertFalse(Url::hasDangerousScheme('https://getkirby.com'));
     		$this->assertFalse(Url::hasDangerousScheme('//getkirby.com'));
     		$this->assertFalse(Url::hasDangerousScheme('/relative/path'));
    
  • tests/Toolkit/HtmlTest.php+13 1 modified
    @@ -135,7 +135,7 @@ public function testAWithDangerousSchemes(): void
     		$html = Html::a('jar://x', 'click');
     		$this->assertStringNotContainsString('jar:', $html);
     
    -		// whitespace/tab/newline prefix bypass attempts
    +		// whitespace/tab/newline bypass attempts
     		$html = Html::a(' javascript://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    @@ -145,6 +145,18 @@ public function testAWithDangerousSchemes(): void
     		$html = Html::a("\njavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    +		$html = Html::a('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("java\tscript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("javasc\nript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
     		// safe schemes must still work
     		$html = Html::a('https://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="https://getkirby.com"', $html);
    
fd132bdae839

fix: More thorough whitespace handling in URL schemes

https://github.com/getkirby/kirbyLukas BestleMay 9, 2026Fixed in 5.4.1via llm-release-walk
3 files changed · +33 6
  • src/Http/Url.php+12 5 modified
    @@ -119,11 +119,18 @@ public static function index(array $props = []): string
     	 */
     	public static function hasDangerousScheme(string|null $url = null): bool
     	{
    -		return
    -			$url !== null &&
    -			// strip leading whitespace to prevent
    -			// tab/space-prefix bypass attempts
    -			preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data)\s*:!i', ltrim($url)) === 1;
    +		if ($url === null) {
    +			return false;
    +		}
    +
    +		// strip any weird characters to prevent bypass attempts,
    +		// keeping only the characters we test for below
    +		// (especially removes any whitespace that the browser would ignore
    +		// when the resulting URL is evaluated)
    +		$url = preg_replace('/[^a-z:]/i', '', $url);
    +
    +		// try to find a match from the blocklist case-insensitively
    +		return preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data):!i', $url) === 1;
     	}
     
     	/**
    
  • tests/Http/UrlTest.php+8 0 modified
    @@ -100,12 +100,20 @@ public function testHasDangerousScheme(): void
     		$this->assertTrue(Url::hasDangerousScheme('mocha://x'));
     		$this->assertTrue(Url::hasDangerousScheme('jar://x'));
     		$this->assertTrue(Url::hasDangerousScheme('JAVASCRIPT://x'));
    +
     		// whitespace prefix bypass attempts
     		$this->assertTrue(Url::hasDangerousScheme(' javascript://x'));
     		$this->assertTrue(Url::hasDangerousScheme("\tjavascript://x"));
     		$this->assertTrue(Url::hasDangerousScheme("\njavascript://x"));
     		$this->assertTrue(Url::hasDangerousScheme("\rjavascript://x"));
     
    +		// whitespace bypass in the middle of the string
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("java\tscript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javas\ncript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javasc\rript://x"));
    +
     		$this->assertFalse(Url::hasDangerousScheme('https://getkirby.com'));
     		$this->assertFalse(Url::hasDangerousScheme('//getkirby.com'));
     		$this->assertFalse(Url::hasDangerousScheme('/relative/path'));
    
  • tests/Toolkit/HtmlTest.php+13 1 modified
    @@ -109,7 +109,7 @@ public function testAWithDangerousSchemes(): void
     		$html = Html::a('jar://x', 'click');
     		$this->assertStringNotContainsString('jar:', $html);
     
    -		// whitespace/tab/newline prefix bypass attempts
    +		// whitespace/tab/newline bypass attempts
     		$html = Html::a(' javascript://x', 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    @@ -119,6 +119,18 @@ public function testAWithDangerousSchemes(): void
     		$html = Html::a("\njavascript://x", 'click');
     		$this->assertStringNotContainsString('javascript:', $html);
     
    +		$html = Html::a('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("java\tscript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("javasc\nript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
     		// safe schemes must still work
     		$html = Html::a('https://getkirby.com', 'Kirby');
     		$this->assertStringContainsString('href="https://getkirby.com"', $html);
    
b975d2dc8a66

Merge pull request #50 from getkirby/security/v4/prevent-dangerous-schemes

https://github.com/getkirby/kirbyBastian AllgeierMay 6, 2026Fixed in 4.9.1via llm-release-walk
4 files changed · +100 3
  • src/Http/Url.php+20 3 modified
    @@ -93,18 +93,35 @@ public static function index(array $props = []): string
     		return Uri::index($props)->toString();
     	}
     
    +	/**
    +	 * Checks if a URL starts with a dangerous URI scheme
    +	 * (e.g. javascript:) that must never appear in rendered href
    +	 * or src attributes.
    +	 * @since 4.9.1
    +	 */
    +	public static function hasDangerousScheme(string|null $url = null): bool
    +	{
    +		return
    +			$url !== null &&
    +			// strip leading whitespace to prevent
    +			// tab/space-prefix bypass attempts
    +			preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data)\s*:!i', ltrim($url)) === 1;
    +	}
    +
     	/**
     	 * Checks if an URL is absolute
     	 */
     	public static function isAbsolute(string|null $url = null): bool
     	{
    +		if ($url === null || static::hasDangerousScheme($url) === true) {
    +			return false;
    +		}
    +
     		// matches the following groups of URLs:
     		//  //example.com/uri
     		//  http://example.com/uri, https://example.com/uri, ftp://example.com/uri
     		//  mailto:example@example.com, geo:49.0158,8.3239?z=11
    -		return
    -			$url !== null &&
    -			preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
    +		return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
     	}
     
     	/**
    
  • src/Toolkit/Html.php+4 0 modified
    @@ -120,6 +120,10 @@ public static function a(string $href, $text = null, array $attr = []): string
     			return static::tel(substr($href, 4), $text, $attr);
     		}
     
    +		if (Url::hasDangerousScheme($href) === true) {
    +			$href = '';
    +		}
    +
     		return static::link($href, $text, $attr);
     	}
     
    
  • tests/Http/UrlTest.php+25 0 modified
    @@ -74,6 +74,28 @@ public function testBuild()
     		$this->assertSame($result, Url::build($parts, 'http://getkirby.com'));
     	}
     
    +	public function testHasDangerousScheme(): void
    +	{
    +		$this->assertTrue(Url::hasDangerousScheme('javascript:alert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('javascript://comment%0Aalert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('vbscript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('data://text/html;base64,PHN2Zz4='));
    +		$this->assertTrue(Url::hasDangerousScheme('livescript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('mocha://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('jar://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('JAVASCRIPT://x'));
    +		// whitespace prefix bypass attempts
    +		$this->assertTrue(Url::hasDangerousScheme(' javascript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("\tjavascript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("\njavascript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("\rjavascript://x"));
    +
    +		$this->assertFalse(Url::hasDangerousScheme('https://getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('//getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('/relative/path'));
    +		$this->assertFalse(Url::hasDangerousScheme(null));
    +	}
    +
     	public function testIsAbsolute()
     	{
     		$this->assertTrue(Url::isAbsolute('http://getkirby.com/docs'));
    @@ -84,6 +106,9 @@ public function testIsAbsolute()
     		$this->assertTrue(Url::isAbsolute('geo:49.0158,8.3239?z=11'));
     		$this->assertFalse(Url::isAbsolute('../getkirby.com/docs'));
     		$this->assertFalse(Url::isAbsolute('javascript:alert("XSS")'));
    +		$this->assertFalse(Url::isAbsolute('javascript://comment%0Aalert(1)'));
    +		$this->assertFalse(Url::isAbsolute('vbscript://x'));
    +		$this->assertFalse(Url::isAbsolute('data://text/html;base64,PHN2Zz4='));
     	}
     
     	public function testMakeAbsolute()
    
  • tests/Toolkit/HtmlTest.php+51 0 modified
    @@ -105,6 +105,57 @@ public function testAWithTargetAndRel()
     		$this->assertSame($expected, $html);
     	}
     
    +	/**
    +	 * @covers ::a
    +	 */
    +	public function testAWithDangerousSchemes(): void
    +	{
    +		// javascript://
    +		$html = Html::a('javascript://comment%0Aalert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +
    +		// bare javascript: (no //)
    +		$html = Html::a('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// other dangerous schemes
    +		$html = Html::a('vbscript://x', 'click');
    +		$this->assertStringNotContainsString('vbscript:', $html);
    +
    +		$html = Html::a('data://text/html;base64,PHN2Zz4=', 'click');
    +		$this->assertStringNotContainsString('data:', $html);
    +
    +		$html = Html::a('livescript://x', 'click');
    +		$this->assertStringNotContainsString('livescript:', $html);
    +
    +		$html = Html::a('mocha://x', 'click');
    +		$this->assertStringNotContainsString('mocha:', $html);
    +
    +		$html = Html::a('jar://x', 'click');
    +		$this->assertStringNotContainsString('jar:', $html);
    +
    +		// whitespace/tab/newline prefix bypass attempts
    +		$html = Html::a(' javascript://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("\tjavascript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("\njavascript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// safe schemes must still work
    +		$html = Html::a('https://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="https://getkirby.com"', $html);
    +
    +		$html = Html::a('custom://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="custom://getkirby.com"', $html);
    +
    +		$html = Html::a('/relative/path', 'link');
    +		$this->assertStringContainsString('href="/relative/path"', $html);
    +	}
    +
     	/**
     	 * @covers       ::attr
     	 * @dataProvider attrProvider
    
d23af9f00569

fix: Safe scheme `Html::a()`/`Url::isAbsolute()`

https://github.com/getkirby/kirbyNico HoffmannMay 1, 2026Fixed in 5.4.1via llm-release-walk
4 files changed · +82 3
  • src/Http/Url.php+20 3 modified
    @@ -111,18 +111,35 @@ public static function index(array $props = []): string
     		return Uri::index($props)->toString();
     	}
     
    +	/**
    +	 * Checks if a URL starts with a dangerous URI scheme
    +	 * (e.g. javascript:) that must never appear in rendered href
    +	 * or src attributes.
    +	 * @since 5.4.1
    +	 */
    +	public static function hasDangerousScheme(string|null $url = null): bool
    +	{
    +		return
    +			$url !== null &&
    +			// strip leading whitespace to prevent
    +			// tab/space-prefix bypass attempts
    +			preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data)\s*:!i', ltrim($url)) === 1;
    +	}
    +
     	/**
     	 * Checks if an URL is absolute
     	 */
     	public static function isAbsolute(string|null $url = null): bool
     	{
    +		if ($url === null || static::hasDangerousScheme($url) === true) {
    +			return false;
    +		}
    +
     		// matches the following groups of URLs:
     		//  //example.com/uri
     		//  http://example.com/uri, https://example.com/uri, ftp://example.com/uri
     		//  mailto:example@example.com, geo:49.0158,8.3239?z=11
    -		return
    -			$url !== null &&
    -			preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
    +		return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
     	}
     
     	/**
    
  • src/Toolkit/Html.php+4 0 modified
    @@ -119,6 +119,10 @@ public static function a(
     			return static::tel(substr($href, 4), $text, $attr);
     		}
     
    +		if (Url::hasDangerousScheme($href) === true) {
    +			$href = '';
    +		}
    +
     		return static::link($href, $text, $attr);
     	}
     
    
  • tests/Http/UrlTest.php+19 0 modified
    @@ -90,6 +90,24 @@ public function testBuild(): void
     		$this->assertSame($result, Url::build($parts, 'http://getkirby.com'));
     	}
     
    +	public function testHasDangerousScheme(): void
    +	{
    +		$this->assertTrue(Url::hasDangerousScheme('javascript:alert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('javascript://comment%0Aalert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('vbscript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('data://text/html;base64,PHN2Zz4='));
    +		$this->assertTrue(Url::hasDangerousScheme('livescript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('JAVASCRIPT://x'));
    +		// whitespace prefix bypass attempts
    +		$this->assertTrue(Url::hasDangerousScheme(' javascript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("\tjavascript://x"));
    +
    +		$this->assertFalse(Url::hasDangerousScheme('https://getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('//getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('/relative/path'));
    +		$this->assertFalse(Url::hasDangerousScheme(null));
    +	}
    +
     	public function testIsAbsolute(): void
     	{
     		$this->assertTrue(Url::isAbsolute('http://getkirby.com/docs'));
    @@ -100,6 +118,7 @@ public function testIsAbsolute(): void
     		$this->assertTrue(Url::isAbsolute('geo:49.0158,8.3239?z=11'));
     		$this->assertFalse(Url::isAbsolute('../getkirby.com/docs'));
     		$this->assertFalse(Url::isAbsolute('javascript:alert("XSS")'));
    +		$this->assertFalse(Url::isAbsolute('javascript://comment%0Aalert(1)'));
     	}
     
     	public function testMakeAbsolute(): void
    
  • tests/Toolkit/HtmlTest.php+39 0 modified
    @@ -82,6 +82,45 @@ public function testAWithTargetAndRel(): void
     		$this->assertSame($expected, $html);
     	}
     
    +	public function testAWithDangerousSchemes(): void
    +	{
    +		// javascript://
    +		$html = Html::a('javascript://comment%0Aalert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +
    +		// bare javascript: (no //)
    +		$html = Html::a('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// other dangerous schemes
    +		$html = Html::a('vbscript://x', 'click');
    +		$this->assertStringNotContainsString('vbscript:', $html);
    +
    +		$html = Html::a('data://text/html;base64,PHN2Zz4=', 'click');
    +		$this->assertStringNotContainsString('data:', $html);
    +
    +		$html = Html::a('livescript://x', 'click');
    +		$this->assertStringNotContainsString('livescript:', $html);
    +
    +		// whitespace/tab prefix bypass attempts
    +		$html = Html::a(' javascript://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::a("\tjavascript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// safe schemes must still work
    +		$html = Html::a('https://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="https://getkirby.com"', $html);
    +
    +		$html = Html::a('custom://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="custom://getkirby.com"', $html);
    +
    +		$html = Html::a('/relative/path', 'link');
    +		$this->assertStringContainsString('href="/relative/path"', $html);
    +	}
    +
     	#[DataProvider('attrProvider')]
     	public function testAttr(
     		array $input,
    
afc650c0d4e6

fix: Apply block attribute on `Form` classes

https://github.com/getkirby/kirbyLukas BestleMay 14, 2026Fixed in 5.4.1via llm-release-walk
4 files changed · +10 0
  • src/Form/Field/BlocksField.php+2 0 modified
    @@ -16,6 +16,7 @@
     use Kirby\Form\Mixin\EmptyState;
     use Kirby\Form\Mixin\Max;
     use Kirby\Form\Mixin\Min;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -108,6 +109,7 @@ public function fieldsetGroups(): array|null
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$value  = BlocksCollection::parse($value);
    
  • src/Form/Field/EntriesField.php+2 0 modified
    @@ -10,6 +10,7 @@
     use Kirby\Form\Mixin\Max;
     use Kirby\Form\Mixin\Min;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -58,6 +59,7 @@ public function fieldProps(): array
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$this->value = Data::decode($value ?? '', 'yaml');
    
  • src/Form/Field/LayoutField.php+2 0 modified
    @@ -11,6 +11,7 @@
     use Kirby\Data\Json;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Form\Form;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -34,6 +35,7 @@ public function __construct(array $params)
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$attrs   = $this->attrsForm();
    
  • src/Form/Mixin/Value.php+4 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Form\Mixin;
     
     use Kirby\Cms\Language;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use ReflectionProperty;
     
     /**
    @@ -63,6 +64,7 @@ public function emptyValue(): mixed
     	/**
     	 * Sets a new value for the field
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$this->value = $value;
    @@ -180,6 +182,7 @@ protected function setDefault(mixed $default = null): void
     	 *
     	 * @since 5.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function submit(mixed $value): static
     	{
     		return $this->fill($value);
    @@ -217,6 +220,7 @@ public function toStoredValue(): mixed
     	 * If you need the form value with the default as fallback, you should use
     	 * the fill method first `$field->fill($field->default())->toFormValue()`
     	 */
    +	#[BlockCollectionAccess]
     	public function value(bool $default = false): mixed
     	{
     		if ($default === true && $this->isEmpty() === true) {
    
a79e12627733

fix: Remove unnecessary `$isExternal` argument

https://github.com/getkirby/kirbyBastian AllgeierMay 6, 2026Fixed in 4.9.1via llm-release-walk
1 file changed · +3 5
  • src/Sane/Sane.php+3 5 modified
    @@ -135,11 +135,9 @@ 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);
    +	public static function sanitizeProseMirrorFields(string $string): string
    +	{
    +		$string = static::sanitize($string, 'html');
     
     		// convert non-breaking spaces to HTML entity
     		// as that's how ProseMirror handles it internally;
    
36ef2a6d7f61
https://github.com/getkirby/kirbyFixed in 5.4.1via llm-release-walk

Vulnerability mechanics

Root cause

"Insufficient validation of URI schemes in URL handling allows dangerous schemes like `javascript:` to be rendered in `<a href>` attributes."

Attack vector

An authenticated Panel user with update permission to any `textarea` or `blocks` field, or write access to content files via another vector (e.g. a frontend form or content sync pipeline), can inject a malicious URL into a link target. The attacker uses a URL of the format `javascript://x%0A…` or other dangerous schemes (`vbscript:`, `data:`, `livescript:`, `mocha:`, `jar:`) that bypass the previous single-slash prefix protection. When a site visitor or logged-in user clicks the rendered link in the site frontend, the browser executes the JavaScript payload in the origin of the current page, potentially giving the attacker full control of the victim's Panel session.

Affected code

The vulnerability spans four first-party renderers: the `(link: …)` KirbyTag, the `link:` parameter of the `(image: …)` KirbyTag, the link field of the built-in `image` block, and the HTML importer for blocks fields. The core fix is in `src/Http/Url.php` (new `hasDangerousScheme()` method and hardened `isAbsolute()`), `src/Toolkit/Html.php` (dangerous-scheme check in `link()` and `a()`), `src/Parsley/Inline.php` (href filtering during HTML import), and `src/Parsley/Schema/Blocks.php` (link stripping for image blocks) [patch_id=2719709][patch_id=2719710][patch_id=2719711][patch_id=2719705].

What the fix does

The patches introduce a new `Url::hasDangerousScheme()` method that detects URI schemes (`javascript:`, `vbscript:`, `livescript:`, `mocha:`, `jar:`, `data:`) that must never appear in rendered `href` or `src` attributes [patch_id=2719709]. This method strips all non-alphabetic/non-colon characters before matching, preventing whitespace-based bypass attempts such as `java\tscript:` [patch_id=2719711]. `Url::isAbsolute()` now returns `false` for dangerous URLs so they are no longer passed through `makeAbsolute()` unchanged [patch_id=2719709]. `Html::link()` replaces the `href` with an empty string when a dangerous scheme is detected, causing the rendered `<a>` tag to link back to the current page rather than executing injected script [patch_id=2719710]. The HTML importer for blocks (`Parsley/Inline.php` and `Parsley/Schema/Blocks.php`) strips dangerous hrefs and link targets during import [patch_id=2719705].

Preconditions

  • authAttacker must be an authenticated Panel user with update permission to a textarea or blocks field, or have write access to content files through another vector (e.g. frontend form, content sync pipeline)
  • configThe site must use the (link:) KirbyTag, the link: parameter of (image:), the built-in image block with a link, or the HTML importer for blocks
  • inputVictim must click the malicious link rendered in the site frontend

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.