Craft Affected by Authenticated RCE via "craft.app.fs.write()" in Twig Templates
Description
Craft is a content management system (CMS). Prior to 4.17.0-beta.1 and 5.9.0-beta.1, an authenticated administrator can achieve Remote Code Execution (RCE) by injecting a Server-Side Template Injection (SSTI) payload into Twig template fields (e.g., Email Templates). By calling the craft.app.fs.write() method, an attacker can write a malicious PHP script to a web-accessible directory and subsequently access it via the browser to execute arbitrary system commands. This vulnerability is fixed in 4.17.0-beta.1 and 5.9.0-beta.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Craft CMS 4.x/5.x prior to beta.1 allows authenticated admins to achieve RCE via SSTI in Twig template fields using craft.app.fs.write().
Vulnerability
Overview
CVE-2026-28697 is a Server-Side Template Injection (SSTI) vulnerability in Craft CMS affecting versions prior to 4.17.0-beta.1 and 5.9.0-beta.1. An authenticated administrator with 'allowAdminChanges' enabled, or access to the System Messages utility, can inject malicious Twig template code into fields such as Email Templates. The root cause is that certain Twig methods were not restricted by the sandbox, allowing access to craft.app.fs.write() and related filesystem methods [1][3].
Exploitation
Details
The attack requires an authenticated administrator account. The proof of concept involves navigating to the System Messages utility, editing an email template, and inserting a payload like {{ craft.app.fs.getFilesystemByHandle('hardDisk').write('shell.php', '...') }}. When the administrator triggers a test email via Settings > Email, the Twig template is rendered, executing the payload. This writes a malicious PHP webshell to a web-accessible directory. The attacker can then access the webshell via a browser to execute arbitrary system commands, as demonstrated by the 'id' command returning output like 'uid=33(www-data)' [3].
Impact
Successful exploitation grants the attacker remote code execution (RCE) on the server under the web server user's privileges. This can lead to full compromise of the CMS instance, including data exfiltration, lateral movement within the hosting environment, and potential persistence. Given that the vulnerability is authenticated but requires administrative privileges, it poses a high risk to production systems where administrators may be targeted or where shared hosting environments are involved [1][3].
Mitigation
The vulnerability is patched in Craft CMS versions 4.17.0-beta.1 and 5.9.0-beta.1. The fix includes adding an #[AllowedInSandbox] attribute to certain Element methods and restricting access to filesystem operations within Twig templates [2][3]. Administrators should upgrade immediately. As a temporary workaround, disabling 'allowAdminChanges' or restricting access to the System Messages utility may reduce risk, but upgrading is the only complete mitigation.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
craftcms/cmsPackagist | >= 5.0.0-RC1, < 5.9.0-beta.1 | 5.9.0-beta.1 |
craftcms/cmsPackagist | >= 4.0.0-RC1, < 4.17.0-beta.1 | 4.17.0-beta.1 |
Affected products
1Patches
19dc2a4a3ec8eAllowedInSandbox attribute
13 files changed · +159 −122
src/base/ElementInterface.php+8 −0 modified@@ -15,6 +15,7 @@ use craft\errors\InvalidFieldException; use craft\models\FieldLayout; use craft\models\Site; +use craft\web\twig\AllowedInSandbox; use Illuminate\Support\Collection; use Twig\Markup; use yii\web\Response; @@ -689,6 +690,7 @@ public function getFieldLayout(): ?FieldLayout; * * @return Site */ + #[AllowedInSandbox] public function getSite(): Site; /** @@ -697,6 +699,7 @@ public function getSite(): Site; * @return string * @since 3.5.0 */ + #[AllowedInSandbox] public function getLanguage(): string; /** @@ -751,20 +754,23 @@ public function getRoute(): mixed; * @return bool * @since 3.3.6 */ + #[AllowedInSandbox] public function getIsHomepage(): bool; /** * Returns the element’s full URL. * * @return string|null */ + #[AllowedInSandbox] public function getUrl(): ?string; /** * Returns an anchor pre-filled with this element’s URL and title. * * @return Markup|null */ + #[AllowedInSandbox] public function getLink(): ?Markup; /** @@ -911,6 +917,7 @@ public function prepareEditScreen(Response $response, string $containerId): void * * @return string|null */ + #[AllowedInSandbox] public function getCpEditUrl(): ?string; /** @@ -1024,6 +1031,7 @@ public function setEnabledForSite(array|bool $enabledForSite): void; * * @return string|null */ + #[AllowedInSandbox] public function getStatus(): ?string; /**
src/base/ElementTrait.php+13 −0 modified@@ -7,6 +7,7 @@ namespace craft\base; +use craft\web\twig\AllowedInSandbox; use DateTime; /** @@ -20,6 +21,7 @@ trait ElementTrait /** * @var int|null The element’s ID */ + #[AllowedInSandbox] public ?int $id = null; /** @@ -48,6 +50,7 @@ trait ElementTrait /** * @var string|null The element’s UID */ + #[AllowedInSandbox] public ?string $uid = null; /** @@ -74,41 +77,49 @@ trait ElementTrait /** * @var bool Whether the element is enabled */ + #[AllowedInSandbox] public bool $enabled = true; /** * @var bool Whether the element is archived */ + #[AllowedInSandbox] public bool $archived = false; /** * @var int|null The site ID the element is associated with */ + #[AllowedInSandbox] public ?int $siteId = null; /** * @var string|null The element’s title */ + #[AllowedInSandbox] public ?string $title = null; /** * @var string|null The element’s slug */ + #[AllowedInSandbox] public ?string $slug = null; /** * @var string|null The element’s URI */ + #[AllowedInSandbox] public ?string $uri = null; /** * @var DateTime|null The date that the element was created */ + #[AllowedInSandbox] public ?DateTime $dateCreated = null; /** * @var DateTime|null The date that the element was last updated */ + #[AllowedInSandbox] public ?DateTime $dateUpdated = null; /** @@ -121,6 +132,7 @@ trait ElementTrait * @var DateTime|null The date that the element was trashed * @since 3.2.0 */ + #[AllowedInSandbox] public ?DateTime $dateDeleted = null; /** @@ -151,6 +163,7 @@ trait ElementTrait /** * @var bool Whether the element has been soft-deleted. */ + #[AllowedInSandbox] public bool $trashed = false; /**
src/config/twig-sandbox.php+2 −122 modified@@ -1,12 +1,5 @@ <?php -use craft\base\ElementInterface; -use craft\elements\Address; -use craft\elements\Asset; -use craft\elements\Entry; -use craft\elements\User; -use craft\models\Site; -use craft\models\UserGroup; return [ 'allowedTags' => [ @@ -139,119 +132,6 @@ 'url', 'uuid', ], - 'allowedMethods' => [ - Address::class => [ - 'getCountryCode', - 'getCountry', - 'getAdministrativeArea', - 'getLocality', - 'getDependentLocality', - 'getPostalCode', - 'getSortingCode', - 'getAddressLine1', - 'getAddressLine2', - 'getOrganization', - 'getGivenName', - 'getAdditionalName', - 'getFamilyName', - 'getLocale', - ], - Asset::class => [ - 'getDataUrl', - 'getDimensions', - 'getExtension', - 'getFilename', - 'getFormat', - 'getFormattedSize', - 'getFormattedSizeInBytes', - 'getHeight', - 'getImg', - 'getMimeType', - 'getSrcset', - 'getUrlsBySize', - 'getWidth', - ], - ElementInterface::class => [ - 'getCpEditUrl', - 'getIsHomepage', - 'getLanguage', - 'getLink', - 'getSite', - 'getStatus', - 'getUrl', - ], - Entry::class => [ - 'getAuthor', - 'getAuthorId', - ], - Site::class => [ - 'getBaseUrl', - 'getEnabled', - 'getName', - ], - User::class => [ - 'getAddresses', - 'getFriendlyName', - 'getFullName', - 'getGroups', - 'getName', - 'getPhoto', - 'isInGroup', - ], - ], - 'allowedProperties' => [ - Address::class => [ - 'latitude', - 'longitude', - 'organizationTaxId', - ], - Asset::class => [ - 'alt', - 'kind', - 'size', - ], - ElementInterface::class => [ - 'archived', - 'dateCreated', - 'dateDeleted', - 'dateUpdated', - 'enabled', - 'id', - 'ownerId', - 'siteId', - 'slug', - 'title', - 'trashed', - 'uid', - 'uri', - ], - Entry::class => [ - 'postDate', - 'expiryDate', - ], - Site::class => [ - 'handle', - 'hasUrls', - 'id', - 'language', - 'primary', - ], - User::class => [ - 'active', - 'admin', - 'email', - 'lastLoginDate', - 'locked', - 'pending', - 'photoId', - 'suspended', - 'username', - ], - UserGroup::class => [ - 'id', - 'name', - 'handle', - 'description', - ], - ], + 'allowedMethods' => [], + 'allowedProperties' => [], ];
src/elements/Address.php+28 −0 modified@@ -21,6 +21,7 @@ use craft\models\FieldLayout; use craft\records\Address as AddressRecord; use craft\validators\StringValidator; +use craft\web\twig\AllowedInSandbox; use yii\base\InvalidConfigException; /** @@ -172,6 +173,7 @@ public static function gqlTypeNameByContext(mixed $context): string /** * @var int|null Owner ID */ + #[AllowedInSandbox] public ?int $ownerId = null; /** @@ -184,61 +186,73 @@ public static function gqlTypeNameByContext(mixed $context): string * @var string Two-letter country code * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 */ + #[AllowedInSandbox] public string $countryCode; /** * @var string|null Administrative area */ + #[AllowedInSandbox] public ?string $administrativeArea = null; /** * @var string|null Locality */ + #[AllowedInSandbox] public ?string $locality = null; /** * @var string|null Dependent locality */ + #[AllowedInSandbox] public ?string $dependentLocality = null; /** * @var string|null Postal code */ + #[AllowedInSandbox] public ?string $postalCode = null; /** * @var string|null Sorting code */ + #[AllowedInSandbox] public ?string $sortingCode = null; /** * @var string|null First line of the address */ + #[AllowedInSandbox] public ?string $addressLine1 = null; /** * @var string|null Second line of the address */ + #[AllowedInSandbox] public ?string $addressLine2 = null; /** * @var string|null Organization name */ + #[AllowedInSandbox] public ?string $organization = null; /** * @var string|null Organization tax ID */ + #[AllowedInSandbox] public ?string $organizationTaxId = null; /** * @var string|null Latitude */ + #[AllowedInSandbox] public ?string $latitude = null; /** * @var string|null Longitude */ + #[AllowedInSandbox] public ?string $longitude = null; /** @@ -404,6 +418,7 @@ public function canCreateDrafts(User $user): bool /** * @inheritdoc */ + #[AllowedInSandbox] public function getCountryCode(): string { return $this->countryCode; @@ -415,6 +430,7 @@ public function getCountryCode(): string * @return Country * @since 4.11.0 */ + #[AllowedInSandbox] public function getCountry(): Country { return Craft::$app->getAddresses()->getCountryRepository()->get($this->countryCode, Craft::$app->language); @@ -423,6 +439,7 @@ public function getCountry(): Country /** * @inheritdoc */ + #[AllowedInSandbox] public function getAdministrativeArea(): ?string { return $this->administrativeArea; @@ -431,6 +448,7 @@ public function getAdministrativeArea(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getLocality(): ?string { return $this->locality; @@ -439,6 +457,7 @@ public function getLocality(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getDependentLocality(): ?string { return $this->dependentLocality; @@ -447,6 +466,7 @@ public function getDependentLocality(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getPostalCode(): ?string { return $this->postalCode; @@ -455,6 +475,7 @@ public function getPostalCode(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getSortingCode(): ?string { return $this->sortingCode; @@ -463,6 +484,7 @@ public function getSortingCode(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getAddressLine1(): ?string { return $this->addressLine1; @@ -471,6 +493,7 @@ public function getAddressLine1(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getAddressLine2(): ?string { return $this->addressLine2; @@ -479,6 +502,7 @@ public function getAddressLine2(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getOrganization(): ?string { return $this->organization; @@ -487,6 +511,7 @@ public function getOrganization(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getGivenName(): ?string { return $this->firstName; @@ -495,6 +520,7 @@ public function getGivenName(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getAdditionalName(): ?string { return null; @@ -503,6 +529,7 @@ public function getAdditionalName(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getFamilyName(): ?string { return $this->lastName; @@ -511,6 +538,7 @@ public function getFamilyName(): ?string /** * @inheritdoc */ + #[AllowedInSandbox] public function getLocale(): string { return Craft::$app->language;
src/elements/Asset.php+17 −0 modified@@ -69,6 +69,7 @@ use craft\validators\DateTimeValidator; use craft\validators\StringValidator; use craft\web\CpScreenResponseBehavior; +use craft\web\twig\AllowedInSandbox; use DateTime; use Throwable; use Twig\Markup; @@ -975,17 +976,20 @@ private static function isFolderIndex(): bool /** * @var string|null Kind */ + #[AllowedInSandbox] public ?string $kind = null; /** * @var string|null Alternative text * @since 4.0.0 */ + #[AllowedInSandbox] public ?string $alt = null; /** * @var int|null Size */ + #[AllowedInSandbox] public ?int $size = null; /** @@ -1617,6 +1621,7 @@ public function getAdditionalButtons(): string * @return Markup|null * @throws InvalidArgumentException */ + #[AllowedInSandbox] public function getImg(mixed $transform = null, ?array $sizes = null): ?Markup { if ($this->kind !== self::KIND_IMAGE) { @@ -1673,6 +1678,7 @@ public function getImg(mixed $transform = null, ?array $sizes = null): ?Markup * @throws InvalidArgumentException * @since 3.5.0 */ + #[AllowedInSandbox] public function getSrcset(array $sizes, mixed $transform = null): string|false { $urls = array_filter($this->getUrlsBySize($sizes, $transform)); @@ -1721,6 +1727,7 @@ public function getSrcset(array $sizes, mixed $transform = null): string|false * @return array * @since 3.7.16 */ + #[AllowedInSandbox] public function getUrlsBySize(array $sizes, mixed $transform = null): array { if ($this->kind !== self::KIND_IMAGE) { @@ -2156,6 +2163,7 @@ public function getPreviewTargets(): array * @return string * @throws InvalidConfigException if the filename isn’t set yet */ + #[AllowedInSandbox] public function getFilename(bool $withExtension = true): string { if ($this->isFolder) { @@ -2189,6 +2197,7 @@ public function setFilename(string $filename): void * * @return string */ + #[AllowedInSandbox] public function getExtension(): string { return pathinfo($this->_filename, PATHINFO_EXTENSION); @@ -2201,6 +2210,7 @@ public function getExtension(): string * @return string|null * @throws ImageTransformException if $transform is an invalid transform handle */ + #[AllowedInSandbox] public function getMimeType(mixed $transform = null): ?string { $transform = $transform ?? $this->_transform; @@ -2223,6 +2233,7 @@ public function getMimeType(mixed $transform = null): ?string * @return string The asset's format * @throws ImageTransformException If an invalid transform handle is supplied */ + #[AllowedInSandbox] public function getFormat(mixed $transform = null): string { $ext = $this->getExtension(); @@ -2241,6 +2252,7 @@ public function getFormat(mixed $transform = null): string * @param ImageTransform|string|array|null $transform A transform handle or configuration that should be applied to the image * @return int|null */ + #[AllowedInSandbox] public function getHeight(mixed $transform = null): ?int { return $this->_dimensions($transform)[1]; @@ -2262,6 +2274,7 @@ public function setHeight(?int $height): void * @param array|string|ImageTransform|null $transform A transform handle or configuration that should be applied to the image * @return int|null */ + #[AllowedInSandbox] public function getWidth(array|string|ImageTransform $transform = null): ?int { return $this->_dimensions($transform)[0]; @@ -2285,6 +2298,7 @@ public function setWidth(?int $width): void * @return string|null * @since 3.4.0 */ + #[AllowedInSandbox] public function getFormattedSize(?int $decimals = null, bool $short = true): ?string { if (!isset($this->size)) { @@ -2303,6 +2317,7 @@ public function getFormattedSize(?int $decimals = null, bool $short = true): ?st * @return string|null * @since 3.4.0 */ + #[AllowedInSandbox] public function getFormattedSizeInBytes(bool $short = true): ?string { if (!isset($this->size)) { @@ -2324,6 +2339,7 @@ public function getFormattedSizeInBytes(bool $short = true): ?string * @return string|null * @since 3.4.0 */ + #[AllowedInSandbox] public function getDimensions(): ?string { $width = $this->getWidth(); @@ -2410,6 +2426,7 @@ public function getContents(): string * @throws AssetException if a stream could not be created * @since 3.5.13 */ + #[AllowedInSandbox] public function getDataUrl(): string { return Html::dataUrlFromString($this->getContents(), $this->getMimeType());
src/elements/Entry.php+5 −0 modified@@ -51,6 +51,7 @@ use craft\validators\DateCompareValidator; use craft\validators\DateTimeValidator; use craft\web\CpScreenResponseBehavior; +use craft\web\twig\AllowedInSandbox; use DateTime; use Illuminate\Support\Collection; use yii\base\Exception; @@ -708,6 +709,7 @@ protected static function prepElementQueryForTableAttribute(ElementQueryInterfac * {{ entry.postDate|date('short') }} * ``` */ + #[AllowedInSandbox] public ?DateTime $postDate = null; /** @@ -724,6 +726,7 @@ protected static function prepElementQueryForTableAttribute(ElementQueryInterfac * {% endif %} * ``` */ + #[AllowedInSandbox] public ?DateTime $expiryDate = null; /** @@ -1212,6 +1215,7 @@ public function getType(): EntryType * @return int|null * @since 4.0.0 */ + #[AllowedInSandbox] public function getAuthorId(): ?int { return $this->_authorId; @@ -1252,6 +1256,7 @@ public function setAuthorId(array|int|string|null $authorId): void * @return User|null * @throws InvalidConfigException if [[authorId]] is set but invalid */ + #[AllowedInSandbox] public function getAuthor(): ?User { if (!isset($this->_author)) {
src/elements/MatrixBlock.php+5 −0 modified@@ -22,6 +22,7 @@ use craft\models\MatrixBlockType as MatrixBlockTypeModel; use craft\records\MatrixBlock as MatrixBlockRecord; use craft\web\assets\matrix\MatrixAsset; +use craft\web\twig\AllowedInSandbox; use Illuminate\Support\Collection; use yii\base\InvalidConfigException; @@ -166,22 +167,26 @@ public static function gqlTypeNameByContext(mixed $context): string /** * @var int|null Field ID */ + #[AllowedInSandbox] public ?int $fieldId = null; /** * @var int|null Primary owner ID * @since 4.0.0 */ + #[AllowedInSandbox] public ?int $primaryOwnerId = null; /** * @var int|null Owner ID */ + #[AllowedInSandbox] public ?int $ownerId = null; /** * @var int|null Type ID */ + #[AllowedInSandbox] public ?int $typeId = null; /**
src/elements/User.php+17 −0 modified@@ -42,6 +42,7 @@ use craft\validators\UniqueValidator; use craft\validators\UsernameValidator; use craft\validators\UserPasswordValidator; +use craft\web\twig\AllowedInSandbox; use DateInterval; use DateTime; use DateTimeZone; @@ -579,42 +580,50 @@ public static function findIdentityByAccessToken($token, $type = null): ?self /** * @var int|null Photo asset ID */ + #[AllowedInSandbox] public ?int $photoId = null; /** * @var bool Active * @since 4.0.0 */ + #[AllowedInSandbox] public bool $active = false; /** * @var bool Pending */ + #[AllowedInSandbox] public bool $pending = false; /** * @var bool Locked */ + #[AllowedInSandbox] public bool $locked = false; /** * @var bool Suspended */ + #[AllowedInSandbox] public bool $suspended = false; /** * @var bool Admin */ + #[AllowedInSandbox] public bool $admin = false; /** * @var string|null Username */ + #[AllowedInSandbox] public ?string $username = null; /** * @var string|null Email */ + #[AllowedInSandbox] public ?string $email = null; /** @@ -625,6 +634,7 @@ public static function findIdentityByAccessToken($token, $type = null): ?self /** * @var DateTime|null Last login date */ + #[AllowedInSandbox] public ?DateTime $lastLoginDate = null; /** @@ -996,6 +1006,7 @@ public function getFieldLayout(): ?FieldLayout * @return Address[] * @since 4.0.0 */ + #[AllowedInSandbox] public function getAddresses(): array { if (!isset($this->_addresses)) { @@ -1129,6 +1140,7 @@ public function getRef(): ?string * * @return UserGroup[] */ + #[AllowedInSandbox] public function getGroups(): array { if (isset($this->_groups)) { @@ -1160,6 +1172,7 @@ public function setGroups(array $groups): void * @param int|string|UserGroup $group The user group model, its handle, or ID. * @return bool */ + #[AllowedInSandbox] public function isInGroup(UserGroup|int|string $group): bool { if (Craft::$app->getEdition() !== Craft::Pro) { @@ -1183,6 +1196,7 @@ public function isInGroup(UserGroup|int|string $group): bool * @return string|null * @deprecated in 4.0.0. [[fullName]] should be used instead. */ + #[AllowedInSandbox] public function getFullName(): ?string { return $this->fullName; @@ -1193,6 +1207,7 @@ public function getFullName(): ?string * * @return string */ + #[AllowedInSandbox] public function getName(): string { if (!isset($this->_name)) { @@ -1233,6 +1248,7 @@ public function setName(string $name): void * * @return string|null */ + #[AllowedInSandbox] public function getFriendlyName(): ?string { if (!isset($this->_friendlyName)) { @@ -1628,6 +1644,7 @@ public function setEagerLoadedElements(string $handle, array $elements): void * * @return Asset|null */ + #[AllowedInSandbox] public function getPhoto(): ?Asset { if (!isset($this->_photo)) {
src/i18n/Locale.php+7 −0 modified@@ -8,6 +8,7 @@ namespace craft\i18n; use Craft; +use craft\web\twig\AllowedInSandbox; use DateTime; use IntlDateFormatter; use NumberFormatter; @@ -238,6 +239,7 @@ class Locale extends BaseObject /** * @var string|null The locale ID. */ + #[AllowedInSandbox] public ?string $id = null; /** @@ -278,6 +280,7 @@ public function __toString(): string * * @return string This locale’s language ID. */ + #[AllowedInSandbox] public function getLanguageID(): string { if (($pos = strpos($this->id, '-')) !== false) { @@ -294,6 +297,7 @@ public function getLanguageID(): string * * @return string|null The locale’s script ID, if it has one. */ + #[AllowedInSandbox] public function getScriptID(): ?string { // Find sub tags @@ -316,6 +320,7 @@ public function getScriptID(): ?string * * @return string|null The locale’s territory ID, if it has one. */ + #[AllowedInSandbox] public function getTerritoryID(): ?string { // Find sub tags @@ -341,6 +346,7 @@ public function getTerritoryID(): ?string * @param string|null $inLocale * @return string */ + #[AllowedInSandbox] public function getDisplayName(?string $inLocale = null): string { // If no target locale is specified, default to this locale @@ -356,6 +362,7 @@ public function getDisplayName(?string $inLocale = null): string * * @return string The language’s orientation. */ + #[AllowedInSandbox] public function getOrientation(): string { if (in_array($this->getLanguageID(), self::$_rtlLanguages, true)) {
src/models/Site.php+10 −0 modified@@ -16,6 +16,7 @@ use craft\validators\LanguageValidator; use craft\validators\UniqueValidator; use craft\validators\UrlValidator; +use craft\web\twig\AllowedInSandbox; use DateTime; use yii\base\InvalidConfigException; @@ -33,6 +34,7 @@ class Site extends Model /** * @var int|null ID */ + #[AllowedInSandbox] public ?int $id = null; /** @@ -43,21 +45,25 @@ class Site extends Model /** * @var string|null Handle */ + #[AllowedInSandbox] public ?string $handle = null; /** * @var string|null Name */ + #[AllowedInSandbox] public ?string $language = null; /** * @var bool Primary site? */ + #[AllowedInSandbox] public bool $primary = false; /** * @var bool Has URLs */ + #[AllowedInSandbox] public bool $hasUrls = true; /** @@ -108,6 +114,7 @@ class Site extends Model * @return string * @since 3.6.0 */ + #[AllowedInSandbox] public function getName(bool $parse = true): string { return ($parse ? App::parseEnv($this->_name) : $this->_name) ?? ''; @@ -131,6 +138,7 @@ public function setName(string $name): void * @return string|null * @since 3.1.0 */ + #[AllowedInSandbox] public function getBaseUrl(bool $parse = true): ?string { if ($this->_baseUrl) { @@ -169,6 +177,7 @@ public function setBaseUrl(?string $baseUrl): void * @return bool|string * @since 4.0.0 */ + #[AllowedInSandbox] public function getEnabled(bool $parse = true): bool|string { if ($this->primary) { @@ -271,6 +280,7 @@ public function getGroup(): SiteGroup * @return Locale * @since 3.5.8 */ + #[AllowedInSandbox] public function getLocale(): Locale { if ($this->language === Craft::$app->language) {
src/models/UserGroup.php+5 −0 modified@@ -12,6 +12,7 @@ use craft\records\UserGroup as UserGroupRecord; use craft\validators\HandleValidator; use craft\validators\UniqueValidator; +use craft\web\twig\AllowedInSandbox; /** * UserGroup model class. @@ -24,22 +25,26 @@ class UserGroup extends Model /** * @var int|null ID */ + #[AllowedInSandbox] public ?int $id = null; /** * @var string|null Name */ + #[AllowedInSandbox] public ?string $name = null; /** * @var string|null Handle */ + #[AllowedInSandbox] public ?string $handle = null; /** * @var string|null Description * @since 3.5.0 */ + #[AllowedInSandbox] public ?string $description = null; /**
src/web/twig/AllowedInSandbox.php+21 −0 added@@ -0,0 +1,21 @@ +<?php +/** + * @link https://craftcms.com/ + * @copyright Copyright (c) Pixel & Tonic, Inc. + * @license https://craftcms.github.io/license/ + */ + +namespace craft\web\twig; + +use Attribute; + +/** + * Attribute EnvName + * + * @author Pixel & Tonic, Inc. <support@pixelandtonic.com> + * @since 5.6.0 + */ +#[Attribute] +class AllowedInSandbox +{ +}
src/web/twig/SecurityPolicy.php+21 −0 modified@@ -7,6 +7,9 @@ namespace craft\web\twig; +use ReflectionException; +use ReflectionMethod; +use ReflectionProperty; use Twig\Markup; use Twig\Sandbox\SecurityNotAllowedFilterError; use Twig\Sandbox\SecurityNotAllowedFunctionError; @@ -166,6 +169,15 @@ public function checkMethodAllowed($obj, $method): void return; } + // see if the method has the AllowedInSandbox attribute + try { + $ref = new ReflectionMethod($obj, $method); + if (!empty($ref->getAttributes(AllowedInSandbox::class))) { + return; + } + } catch (ReflectionException) { + } + $method = strtolower($method); foreach ($this->allowedMethods as $class => $methods) { if ($obj instanceof $class && in_array($method, $methods)) { @@ -179,6 +191,15 @@ public function checkMethodAllowed($obj, $method): void public function checkPropertyAllowed($obj, $property): void { + // see if the property has the AllowedInSandbox attribute + try { + $ref = new ReflectionProperty($obj, $property); + if (!empty($ref->getAttributes(AllowedInSandbox::class))) { + return; + } + } catch (ReflectionException) { + } + foreach ($this->allowedProperties as $class => $properties) { if ($obj instanceof $class && in_array($property, $properties)) { return;
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-v47q-jxvr-p68xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-28697ghsaADVISORY
- github.com/craftcms/cms/commit/9dc2a4a3ec8e9cd5e8c0d1129f36371437519197ghsax_refsource_MISCWEB
- github.com/craftcms/cms/pull/18216ghsax_refsource_MISCWEB
- github.com/craftcms/cms/pull/18219ghsax_refsource_MISCWEB
- github.com/craftcms/cms/security/advisories/GHSA-v47q-jxvr-p68xghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.