1<?php
2
3/*
4 * This file is part of Twig.
5 *
6 * (c) Fabien Potencier
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Twig\Extension;
13
14use Twig\NodeVisitor\SandboxNodeVisitor;
15use Twig\Sandbox\SecurityNotAllowedMethodError;
16use Twig\Sandbox\SecurityNotAllowedPropertyError;
17use Twig\Sandbox\SecurityPolicyInterface;
18use Twig\Sandbox\SourcePolicyInterface;
19use Twig\Source;
20use Twig\TokenParser\SandboxTokenParser;
21
22final class SandboxExtension extends AbstractExtension
23{
24    private $sandboxedGlobally;
25    private $sandboxed;
26    private $policy;
27    private $sourcePolicy;
28
29    public function __construct(SecurityPolicyInterface $policy, $sandboxed = false, ?SourcePolicyInterface $sourcePolicy = null)
30    {
31        if (null !== $sourcePolicy) {
32            trigger_deprecation('twig/twig', '3.27.0', 'The "%s" interface is deprecated with no replacement, do not pass an instance to "%s".', SourcePolicyInterface::class, self::class);
33        }
34
35        $this->policy = $policy;
36        $this->sandboxedGlobally = $sandboxed;
37        $this->sourcePolicy = $sourcePolicy;
38    }
39
40    public function getTokenParsers(): array
41    {
42        return [new SandboxTokenParser()];
43    }
44
45    public function getNodeVisitors(): array
46    {
47        return [new SandboxNodeVisitor()];
48    }
49
50    public function enableSandbox(): void
51    {
52        $this->sandboxed = true;
53    }
54
55    public function disableSandbox(): void
56    {
57        $this->sandboxed = false;
58    }
59
60    public function isSandboxed(?Source $source = null): bool
61    {
62        return $this->sandboxedGlobally || $this->sandboxed || $this->isSourceSandboxed($source);
63    }
64
65    public function isSandboxedGlobally(): bool
66    {
67        return $this->sandboxedGlobally;
68    }
69
70    private function isSourceSandboxed(?Source $source): bool
71    {
72        if (null === $source || null === $this->sourcePolicy) {
73            return false;
74        }
75
76        return $this->sourcePolicy->enableSandbox($source);
77    }
78
79    public function setSecurityPolicy(SecurityPolicyInterface $policy): void
80    {
81        $this->policy = $policy;
82    }
83
84    public function getSecurityPolicy(): SecurityPolicyInterface
85    {
86        return $this->policy;
87    }
88
89    public function checkSecurity($tags, $filters, $functions, ?Source $source = null): void
90    {
91        if ($this->isSandboxed($source)) {
92            $this->policy->checkSecurity($tags, $filters, $functions);
93        }
94    }
95
96    public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void
97    {
98        if ($this->isSandboxed($source)) {
99            try {
100                $this->policy->checkMethodAllowed($obj, $method);
101            } catch (SecurityNotAllowedMethodError $e) {
102                $e->setSourceContext($source);
103                $e->setTemplateLine($lineno);
104
105                throw $e;
106            }
107        }
108    }
109
110    public function checkPropertyAllowed($obj, $property, int $lineno = -1, ?Source $source = null): void
111    {
112        if ($this->isSandboxed($source)) {
113            try {
114                $this->policy->checkPropertyAllowed($obj, $property);
115            } catch (SecurityNotAllowedPropertyError $e) {
116                $e->setSourceContext($source);
117                $e->setTemplateLine($lineno);
118
119                throw $e;
120            }
121        }
122    }
123
124    /**
125     * @throws SecurityNotAllowedMethodError
126     */
127    public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null)
128    {
129        return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage());
130    }
131
132    /**
133     * Materialises a spread operand and runs the policy on every element.
134     *
135     * @internal
136     *
137     * @throws SecurityNotAllowedMethodError
138     */
139    public function ensureSpreadAllowed(iterable $obj, int $lineno = -1, ?Source $source = null): array
140    {
141        $seen = new \SplObjectStorage();
142        if ($obj instanceof \Traversable) {
143            $seen[$obj] = true;
144            $obj = iterator_to_array($obj);
145        }
146
147        $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen);
148
149        return $obj;
150    }
151
152    private function doEnsureToStringAllowed($obj, int $lineno, ?Source $source, \SplObjectStorage $seen)
153    {
154        if (\is_array($obj)) {
155            $this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen);
156
157            return $obj;
158        }
159
160        if (!$this->isSandboxed($source)) {
161            return $obj;
162        }
163
164        if ($obj instanceof \Stringable) {
165            try {
166                $this->policy->checkMethodAllowed($obj, '__toString');
167            } catch (SecurityNotAllowedMethodError $e) {
168                $e->setSourceContext($source);
169                $e->setTemplateLine($lineno);
170
171                throw $e;
172            }
173        }
174
175        // Elements yielded by a Traversable may be string-coerced downstream
176        // (e.g. by `join`/`replace`), bypassing the policy. Check them now.
177        if ($obj instanceof \Traversable) {
178            if (isset($seen[$obj])) {
179                return $obj;
180            }
181            $seen[$obj] = true;
182
183            // IteratorAggregate::getIterator() is idempotent, so we can walk
184            // the elements and return the original object: host code typed
185            // against a specific class (e.g. FormView) keeps working.
186            if ($obj instanceof \IteratorAggregate) {
187                foreach ($obj as $v) {
188                    $this->doEnsureToStringAllowed($v, $lineno, $source, $seen);
189                }
190
191                return $obj;
192            }
193
194            // Single-pass Iterator/Generator: materialise to validate.
195            $array = iterator_to_array($obj);
196            $this->ensureToStringAllowedForArray($array, $lineno, $source, $seen);
197
198            if (!$obj instanceof \Stringable) {
199                return $array;
200            }
201        }
202
203        return $obj;
204    }
205
206    private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, \SplObjectStorage $seen, array &$stack = []): void
207    {
208        foreach ($obj as $k => $v) {
209            if (!$v) {
210                continue;
211            }
212
213            if (!\is_array($v)) {
214                $this->doEnsureToStringAllowed($v, $lineno, $source, $seen);
215                continue;
216            }
217
218            if ($r = \ReflectionReference::fromArrayElement($obj, $k)) {
219                if (isset($stack[$r->getId()])) {
220                    continue;
221                }
222
223                $stack[$r->getId()] = true;
224            }
225
226            $this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack);
227        }
228    }
229}
230