Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
SandboxedTemplateRenderer
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
4
100.00% covered (success)
100.00%
1 / 1
 render
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 createSandbox
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Vendor\Twig\Environment;
12use OCA\Libresign\Vendor\Twig\Error\SyntaxError;
13use OCA\Libresign\Vendor\Twig\Loader\FilesystemLoader;
14use OCA\Libresign\Vendor\Twig\Sandbox\Sandbox;
15use OCA\Libresign\Vendor\Twig\Sandbox\SecurityError;
16use OCA\Libresign\Vendor\Twig\Sandbox\SecurityPolicy;
17
18/**
19 * Render signing templates without granting access to arbitrary PHP callables,
20 * object members or external templates.
21 */
22final class SandboxedTemplateRenderer {
23    /** @param array<string, mixed> $variables */
24    public static function render(string $template, array $variables): string {
25        [$sandbox] = self::createSandbox();
26        return $sandbox->createTemplate($template)->render($variables);
27    }
28
29    /**
30     * Validate Twig syntax and its sandbox permissions without rendering user code.
31     * Used at policy persistence boundaries, including delegated policies.
32     */
33    public static function validate(string $template): void {
34        [$sandbox, $environment] = self::createSandbox();
35        try {
36            $sandbox->createTemplate($template)->unwrap($environment)->ensureSecurityChecked();
37        } catch (SecurityError|SyntaxError $exception) {
38            throw new \InvalidArgumentException($exception->getMessage(), 0, $exception);
39        }
40    }
41
42    /** @return array{Sandbox, Environment} */
43    private static function createSandbox(): array {
44        // Twig 3.30 requires explicit permission for safe built-in tags,
45        // filters and functions. Keep macros disabled to prevent recursion.
46        // TODO: Revisit explicit sandbox permissions when upgrading to Twig 4.
47        // See https://github.com/LibreSign/3rdparty/issues/109.
48        $policy = new SecurityPolicy(
49            allowedTags: ['apply', 'block', 'do', 'for', 'if', 'set', 'types', 'with'],
50            allowedFilters: [
51                'abs', 'batch', 'capitalize', 'convert_encoding', 'default',
52                'e', 'escape', 'first', 'format', 'join', 'keys', 'last',
53                'length', 'lower', 'merge', 'nl2br', 'number_format', 'replace',
54                'reverse', 'round', 'slice', 'split', 'striptags', 'title',
55                'trim', 'upper', 'url_encode',
56                // Existing LibreSign templates use date and raw.
57                'date', 'raw',
58            ],
59            allowedMethods: [],
60            allowedProperties: [],
61            allowedFunctions: ['cycle', 'max', 'min'],
62            // Twig 3.30 natively identifies its always-safe tests.
63            allowedTests: [],
64        );
65        $policy->setStrict(true);
66
67        $environment = new Environment(new FilesystemLoader());
68        return [new Sandbox($environment, $policy), $environment];
69    }
70}