Skip to content
6 changes: 5 additions & 1 deletion config/twig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ services:
Pimcore\Bundle\StudioBackendBundle\Twig\TemplateGeneratorInterface:
class: Pimcore\Bundle\StudioBackendBundle\Twig\TemplateGenerator

# The parameters are handled by the Dependency Injection Extension
# The sandbox lists are set by the Dependency Injection Extension. $additionalExtensions are the services
# tagged pimcore_studio_backend.twig_operator_extension (TWIG_OPERATOR_EXTENSION_TAG), the way to add a
# further safe Twig extension to the isolated environment. $twig (unused, kept for BC) and $logger are autowired.
Pimcore\Bundle\StudioBackendBundle\Twig\Initializers\SandboxExtensionInitializerInterface:
class: Pimcore\Bundle\StudioBackendBundle\Twig\Initializers\SandboxExtensionInitializer
arguments:
$additionalExtensions: !tagged_iterator pimcore_studio_backend.twig_operator_extension
75 changes: 65 additions & 10 deletions doc/01_Architecture_Overview/01_Grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,13 +525,33 @@ In this example, `{{ value.name }} - {{ value.manufacturer.name }}` resolves to

**Available Twig Filters, Functions and Tags:**

Templates are rendered inside a [Twig sandbox](https://twig.symfony.com/doc/3.x/api.html#sandbox-extension).
Only the tags, filters and functions listed below are allowed; anything else (method calls, property
access, file includes, etc.) is rejected and the template fails to render. This prevents arbitrary
code execution through user-provided templates.
Templates are rendered inside a [Twig sandbox](https://twig.symfony.com/doc/3.x/api.html#sandbox-extension),
in a dedicated Twig `Environment` built from scratch for this purpose alone. Only the tags, filters
and functions listed below are allowed; anything else (method calls, property access, file includes,
etc.) is rejected and the template fails to render. This prevents arbitrary code execution through
user-provided templates.

This isolated environment is deliberately not the application's shared `twig` service:

- No Pimcore Twig extension is registered on it, so functions like `pimcore_object`,
`pimcore_asset` or `pimcore_document` do not exist for it to resolve at all - they are not
merely sandboxed, there is no element/service loader reachable here to begin with.
- Values are converted to plain data (scalars, arrays, `null`) before they reach the template:
- dates arrive as ISO 8601 strings; `date`, `date_modify` and `format_date` accept them like a
date object,
- consent values become `{consent, noteId, noteContent}`, `JsonSerializable` objects their
serialized data, backed enums their value and other enums their name,
- any other object renders as empty.
- Method calls and property access on objects are denied, also for objects created inside the
template (e.g. by `date()`); use filters instead.
- The environment uses Twig's defaults and has no settings of its own; the application's Twig
configuration does not apply. Dates use PHP's default timezone and Twig's default format
(`F j, Y H:i`), `number_format` defaults to no decimals, output is HTML-escaped, and
`strict_variables` is off, so an undefined variable or key renders as empty instead of raising
an error. Pass formats explicitly where they matter, e.g. `value.date|date('d.m.Y')`.

- **Tags:** `if`, `for`, `set`
- **Functions:** `date`, `max`, `min`, `random`, `range`
- **Functions:** `date`, `max`, `min`, `random`, `range` (capped - see the range() note below)
- **Filters:**
- *Core:* `abs`, `capitalize`, `date`, `date_modify`, `default`, `escape`, `filter`, `find`,
`first`, `format`, `join`, `json_encode`, `keys`, `last`, `length`, `lower`, `map`, `merge`,
Expand All @@ -543,9 +563,16 @@ code execution through user-provided templates.
- *String* (require [`twig/string-extra`](https://packagist.org/packages/twig/string-extra)):
`plural`, `singular`

> **Note:** The localization and string filters depend on the corresponding Twig extra packages being
> installed and registered (they are auto-registered by `twig/extra-bundle`). The localization filters
> additionally require the PHP `intl` extension.
> **Note:** The localization and string filters depend on the corresponding Twig extra packages
> being installed - the isolated environment registers `Twig\Extra\Intl\IntlExtension` and
> `Twig\Extra\String\StringExtension` on itself directly whenever their classes are present, which
> is independent of whatever the application's own `twig.yaml`/`twig/extra-bundle` configuration
> does for the shared `twig` service. The localization filters additionally require the PHP `intl`
> extension.

> **Resource limits:** the `range()` function returns at most 1000 elements. This is best effort: the
> `..` operator and nested loops are not limited, so `memory_limit` and `max_execution_time` remain the
> limits for expensive templates.

The allow-list can be customized per project via the bundle configuration:

Expand All @@ -558,9 +585,37 @@ pimcore_studio_backend:
functions: [ 'date', 'max', 'min' ]
```

A configured list replaces the default list of the same type, so repeat every default you want to keep. These
lists only apply to Twig operator templates and are independent of core's
`pimcore.templating.twig.sandbox_security_policy` allow-lists. `pimcore_*` function names are ignored (and logged as a
warning): no `pimcore_*` function can be called from a Twig operator template.

> **A name in this list only takes effect if a Twig extension in the isolated environment actually
> registers it.** Because the isolated environment never sees the application's shared `twig`
> service (see above), adding e.g. `trans` or a project-defined filter name here alone does not make
> it available - the template still fails with "is not allowed"/"Unknown filter" at render time, and
> the bundle logs a warning (once per process, when the transformer is created) for any
> allow-listed name nothing registers. To add a project-defined filter, function or tag, register
> your own `Twig\Extension\ExtensionInterface` service tagged
> `pimcore_studio_backend.twig_operator_extension`:
>
> ```yaml
> services:
> App\Twig\MyTwigOperatorExtension:
> autoconfigure: false # otherwise Symfony also adds it to the shared `twig` service
> tags: [ 'pimcore_studio_backend.twig_operator_extension' ]
> ```
>
> (The tag name is also available as `TwigOperatorEnvironmentProviderInterface::TWIG_OPERATOR_EXTENSION_TAG`.)
> It is registered into the isolated environment alongside the built-in extensions, and its
> filter/function/tag names still need to be added to `sandbox_security_policy` above to be usable.
> Keep it narrowly scoped to safe, side-effect-free formatting - it runs in the same sandbox as
> everything else on this page, with the same consequences if it is not. The isolated environment has
> no runtime loader, so filters and functions must be callable directly, not through a Twig runtime
> (`RuntimeExtensionInterface`).

> **Security:** Be careful when extending the allow-list. Filters such as `raw` disable output
> escaping (potential XSS if the value is rendered as HTML), and functions such as `range` combined
> with `for` loops can be abused to build very large outputs. Do not add Twig functions like
> escaping (potential XSS if the value is rendered as HTML). Do not add Twig functions like
> `constant`, `attribute`, `include` or `source`, as they can expose internal data or read files.

---
Expand Down
15 changes: 15 additions & 0 deletions doc/02_Installation_and_Configuration/05_Upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
The following steps are necessary during updating to newer versions.

## Upgrade to 2026.4.0
- [Grid] Changed: `twigOperator` templates render in a dedicated Twig environment with the sandbox always enabled
and without Pimcore's Twig extensions or the application's Twig configuration. Method calls and property access on
objects are denied. Values are converted to plain data first: dates become ISO 8601 strings, consent values,
`JsonSerializable` objects and enums become their data, other objects render as empty. `range()` returns at most
1000 elements (best effort). See `doc/01_Architecture_Overview/01_Grid.md`.

> **Note:** `SandboxExtensionInitializer` implements the new `TwigOperatorEnvironmentProviderInterface`. A custom
> `SandboxExtensionInitializerInterface` implementation or decorator should implement it too: without it, templates keep
> rendering through the shared `twig` service with a deprecation, and fail if the returned sandbox is not registered
> there. `SandboxExtensionInitializer::initialize()` returns the isolated environment's sandbox, which is not registered
> on the shared `twig` service; render through `TemplateGeneratorInterface` instead. The initializer's
> `$blockedClasses`, `$allowedClasses` and `$hardBlockedMethods` arguments no longer apply, since all object access is
> denied. To add a filter, function or tag, tag a Twig extension with `pimcore_studio_backend.twig_operator_extension`
> and add its name to `sandbox_security_policy`.

- [Grid] Added: asset and data object grid rows carry an optional `score` (the search engine score of the hit, `null`
without a scored query). The `Asset` and `DataObject` response schemas implement the new public
`ScoreAwareInterface` (`getScore()`/`setScore()`); subclasses that already declare these methods must match the
Expand Down
4 changes: 2 additions & 2 deletions src/DependencyInjection/PimcoreStudioBackendExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,8 @@ private function populateTwigSandboxExtension(array $config, ContainerBuilder $c
$config['twig']['sandbox_security_policy']['functions']
);

// Reuse core's own object/function protection lists instead of re-declaring them here,
// so this sandbox cannot silently drift from the one Pimcore core configures.
// Core's sandbox lists. The default initializer denies all object access and only applies
// $blockedFunctions; the class and method lists are passed for custom initializers and BC.
$definition->setArgument(
'$blockedClasses',
'%pimcore.templating.twig.sandbox_security_policy.blocked_classes%'
Expand Down
56 changes: 50 additions & 6 deletions src/Grid/Column/Transformer/TwigOperator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,26 @@

namespace Pimcore\Bundle\StudioBackendBundle\Grid\Column\Transformer;

use BackedEnum;
use DateTimeInterface;
use Exception;
use JsonSerializable;
use Pimcore\Bundle\StudioBackendBundle\DataObject\Data\Model\ConsentData;
use Pimcore\Bundle\StudioBackendBundle\Exception\Api\TransformerException;
use Pimcore\Bundle\StudioBackendBundle\Grid\Column\TransformerInterface;
use Pimcore\Bundle\StudioBackendBundle\Grid\Util\AdvancedValue;
use Pimcore\Bundle\StudioBackendBundle\Twig\TemplateGeneratorInterface;
use UnitEnum;
use function array_map;
use function is_array;
use function is_object;
use function is_string;
use function sprintf;

final class TwigOperator implements TransformerInterface
{
private const int MAX_DEPTH = 32;

public function __construct(
private readonly TemplateGeneratorInterface $templateGenerator
) {
Expand All @@ -45,11 +55,10 @@ public function transform(array $value, array $config): array

$template = $config['template'] ?? '{{ value }}';

$context = [
'value' => $this->buildAssociativeContext($value),
];

try {
$context = [
'value' => $this->buildAssociativeContext($value),
];
$rendered = $this->templateGenerator->generate($template, $context);
} catch (Exception $e) {
throw new TransformerException(
Expand Down Expand Up @@ -77,18 +86,53 @@ private function buildAssociativeContext(array $values): array
continue;
}

$value = $this->sanitizeForTemplate($item->getValue());

if ($item->getRelation() !== null) {
$assoc[$item->getRelation()][$item->getFieldName()] = $item->getValue();
$assoc[$item->getRelation()][$item->getFieldName()] = $value;

continue;
}

$assoc[$item->getFieldName()] = $item->getValue();
$assoc[$item->getFieldName()] = $value;
}

return $assoc;
}

/**
* Reduces a value to plain data (scalars, arrays, null) before it reaches the Twig sandbox, so no object
* method or property is reachable from a template:
* - arrays are walked recursively, keys preserved, up to {@see self::MAX_DEPTH} levels;
* - dates become ISO 8601 strings, which the `date` filters accept like a date object;
* - consent values, `JsonSerializable` objects and enums become their data;
* - any other object becomes null; it is never string-cast, which would call `__toString()`.
*/
private function sanitizeForTemplate(mixed $value, int $depth = 0): mixed
{
if ($depth > self::MAX_DEPTH) {
return null;
}

if (is_array($value)) {
return array_map(fn (mixed $item): mixed => $this->sanitizeForTemplate($item, $depth + 1), $value);
}

return match (true) {
$value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM),
$value instanceof ConsentData => [
'consent' => $value->getConsent(),
'noteId' => $value->getNoteId(),
'noteContent' => $value->getNoteContent(),
],
$value instanceof JsonSerializable => $this->sanitizeForTemplate($value->jsonSerialize(), $depth + 1),
$value instanceof BackedEnum => $value->value,
$value instanceof UnitEnum => $value->name,
is_object($value) => null,
default => $value,
};
}

public function getName(): string
{
return 'Twig Operator';
Expand Down
27 changes: 27 additions & 0 deletions src/Twig/Initializers/NoObjectAccessAllowed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\Twig\Initializers;

/**
* Never instantiated. As the only entry of the TwigOperator sandbox's class allowlist it switches
* {@see \Pimcore\Twig\Sandbox\SecurityPolicy} into allowlist mode with nothing allowed, so every method and
* property access on an object is denied. TwigOperator already converts values to plain data; this covers
* objects that reach the template anyway.
*
* @internal
*/
final class NoObjectAccessAllowed
{
}
Loading
Loading