Twig Template Static Analysis
OroEntityBundle provides a static analysis mechanism for Twig templates. It parses a template into an AST, resolves all property and method accesses on typed variables, and can validate those accesses against the Twig sandbox security policy. The primary use case is pre-validating user-authored templates before rendering them in a sandboxed environment.
Architecture
The mechanism is built from the following classes:
Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessAnalyzerEntry point for static analysis. Accepts a raw template source and a variable-to-FQCN map, tokenizes and parses the template, then delegates AST traversal to
AccessNodeVisitor. Returns a flat list ofTemplateAccessEntryobjects — one per resolved property or method access.Oro\Bundle\EntityBundle\Twig\Analyzer\AccessNodeVisitorRecursively walks the Twig AST. Tracks variable types through
forloops andsetassignments usingScopeTracker, resolves eachGetAttrExpressionnode via the registeredTypeResolverInterface, and records aTemplateAccessEntryfor every resolved access.Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessEntryImmutable value object representing a single resolved access. Carries the class name (
$className), the template variable name ($variableName), the attribute or method name ($attributeName), the access type (ACCESS_TYPE_PROPERTYorACCESS_TYPE_METHOD), and the source line number.Oro\Bundle\EntityBundle\Twig\Analyzer\ScopeTrackerMaintains a stack of variable-to-FQCN maps to support nested scopes introduced by
{% for %}loops and{% set %}assignments. Resolves a variable name by searching from the innermost scope outward.Oro\Bundle\EntityBundle\Twig\Analyzer\TypeResolverInterfaceResolves a single attribute access on a given class to a
ResolvedAccessvalue object that carries the canonical attribute name, access type, optional return class, and flags for collections and virtual-variable namespaces (skipAccessEntry).Oro\Bundle\EntityBundle\Twig\Analyzer\ChainTypeResolverImplements
TypeResolverInterfaceas a chain of responsibility. Iterates the registered resolvers in priority order and returns the first non-nullresult. Used as the primary implementation wired in the DI container.Oro\Bundle\EntityBundle\Twig\Analyzer\DoctrineTypeResolverResolves attribute accesses via Doctrine ORM class metadata. Supports both direct property access and getter methods (strips the
getprefix and normalises between camelCase and snake_case). For association fields it returns the target entity class so that chained accesses can be resolved further.Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateRendererConfigTypeResolverResolves virtual variables defined by
EntityVariablesProviderInterfaceimplementations. When an attribute name is a namespace prefix of a dotted virtual variable (e.g.urlwhenurl.viewis a known variable), it returns aResolvedAccesswithskipAccessEntry = trueto prevent false positive security-policy violations.Oro\Bundle\EntityBundle\Twig\Analyzer\NoopResolverFallback resolver. Returns a generic
ResolvedAccessfor any property or method access without performing actual type resolution. Useful when type propagation through a chain is not required.Oro\Bundle\EntityBundle\Twig\SecurityPolicy\TemplateSecurityPolicyCheckerValidates a raw template source against the Twig sandbox security policy. Performs two independent checks and combines their results:
Compile-time check — creates a Twig template object and catches any
SecurityNotAllowedTagError,SecurityNotAllowedFilterError, orSecurityNotAllowedFunctionErrorthrown by the sandbox extension.Static access check — calls
TemplateAccessAnalyzerfor the supplied variable types and tests eachTemplateAccessEntryagainstSandboxExtension::checkPropertyAllowed()orSandboxExtension::checkMethodAllowed().
Returns an empty list when the
SandboxExtensionis not registered or when no violations are found. ThrowsTwig\Error\SyntaxErrorif the template contains syntax errors.Oro\Bundle\EntityBundle\Twig\SecurityPolicy\Violation\SecurityPolicyViolationInterfaceRepresents a single sandbox violation. Concrete subtypes cover the five possible violation kinds — tag, filter, function, property, and method — each carrying the element name, the template variable name (for property/method violations), the entity class, the source line number, and the originating Twig exception.
How to Use
Analyzing Template Accesses
Inject TemplateAccessAnalyzer and call analyzeTemplate() with the raw template source and a
variable-to-FQCN map. The method returns a list of TemplateAccessEntry objects:
use Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessAnalyzer;
use Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessEntry;
// $analyzer is injected via the DI container
$entries = $analyzer->analyzeTemplate(
'{{ entity.firstName }} {{ entity.lastName }}',
['entity' => \Acme\Bundle\Entity\Contact::class],
);
foreach ($entries as $entry) {
// $entry->className - FQCN of the class being accessed
// $entry->variableName - template variable name (e.g. 'entity')
// $entry->attributeName - property or method name
// $entry->accessType - TemplateAccessEntry::ACCESS_TYPE_PROPERTY or ACCESS_TYPE_METHOD
// $entry->lineNumber - source line in the template
}
Checking Security Policy Violations
Inject TemplateSecurityPolicyChecker and call checkSecurityPolicy() with the template source
and an optional variable-to-FQCN map:
use Oro\Bundle\EntityBundle\Twig\SecurityPolicy\TemplateSecurityPolicyChecker;
use Oro\Bundle\EntityBundle\Twig\SecurityPolicy\Violation\SecurityPolicyViolationInterface;
// $checker is injected via the DI container
$violations = $checker->checkSecurityPolicy(
'{{ entity.firstName }}',
['entity' => \Acme\Bundle\Entity\Contact::class],
);
foreach ($violations as $violation) {
// $violation->getName() - disallowed element name
// $violation->getEntityClass() - FQCN (null for tag/filter/function violations)
// $violation->getTemplateLine() - source line (-1 when unavailable)
// $violation->getCause() - original Twig exception
}
When no variable types are passed, only the compile-time sandbox check (tags, filters, functions) is performed. Pass the variable map to also validate property and method accesses.
Wiring Services in the DI Container
The classes in the Analyzer namespace are not registered as global services — each consuming bundle
defines its own service tree scoped to the Twig environment it uses. The pattern used by
OroEmailBundle is:
acme.twig.analyzer.doctrine_type_resolver:
class: Oro\Bundle\EntityBundle\Twig\Analyzer\DoctrineTypeResolver
arguments:
- '@doctrine'
tags:
- { name: acme.twig.analyzer.type_resolver }
acme.twig.analyzer.chain_type_resolver:
class: Oro\Bundle\EntityBundle\Twig\Analyzer\ChainTypeResolver
arguments:
- !tagged_iterator acme.twig.analyzer.type_resolver
acme.twig.analyzer.access_node_visitor:
class: Oro\Bundle\EntityBundle\Twig\Analyzer\AccessNodeVisitor
arguments:
- '@acme.twig.analyzer.chain_type_resolver'
acme.twig.analyzer.template_access_analyzer:
class: Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessAnalyzer
arguments:
- '@twig'
- '@acme.twig.analyzer.access_node_visitor'
acme.twig.security_policy.template_checker:
class: Oro\Bundle\EntityBundle\Twig\SecurityPolicy\TemplateSecurityPolicyChecker
arguments:
- '@twig'
- '@acme.twig.analyzer.template_access_analyzer'
When the sandbox must run in an isolated Twig environment (e.g. a sandboxed email template renderer),
pass that environment instance instead of @twig.
How to Extend
Implementing a Custom Type Resolver
To teach the analyzer about additional attribute types — for example from a custom entity extension or
a non-Doctrine data source — implement TypeResolverInterface and register the service with the
bundle-specific tag:
use Oro\Bundle\EntityBundle\Twig\Analyzer\ResolvedAccess;
use Oro\Bundle\EntityBundle\Twig\Analyzer\TemplateAccessEntry;
use Oro\Bundle\EntityBundle\Twig\Analyzer\TypeResolverInterface;
use Twig\Template;
class AcmeCustomTypeResolver implements TypeResolverInterface
{
public function resolve(string $className, string $attributeName, string $twigCallType): ?ResolvedAccess
{
if ($twigCallType === Template::ARRAY_CALL) {
return null;
}
if ($className !== \Acme\Entity\Product::class || $attributeName !== 'category') {
return null;
}
return new ResolvedAccess(
attributeName: $attributeName,
accessType: TemplateAccessEntry::ACCESS_TYPE_PROPERTY,
entityClass: \Acme\Entity\Category::class,
);
}
}
Register the resolver with the bundle-specific tag and a priority to control evaluation order.
Higher priority values run first. DoctrineTypeResolver uses the default priority (0), so assign
a higher value when the custom resolver must take precedence:
acme.twig.analyzer.custom_type_resolver:
class: Acme\Bundle\Twig\Analyzer\AcmeCustomTypeResolver
tags:
- { name: acme.twig.analyzer.type_resolver, priority: 50 }
TemplateRendererConfigTypeResolver should generally be registered with a higher priority than
DoctrineTypeResolver (for example, priority 100) to prevent false positives for virtual variables
provided by EntityVariablesProviderInterface.