CVE-2022-0911 (PHP, published 2022-03-16T09:15Z)
Description: value: Cross-site Scripting (XSS) - Stored in GitHub repository pimcore/pimcore prior to 10.4.0.
Severity: LOW (CVSS v3 base score: 5.4)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Repository: https://github.com/pimcore/pimcore
Files changed:
bundles/AdminBundle/DependencyInjection/Compiler/ContentSecurityPolicyUrlsPass.php
bundles/AdminBundle/DependencyInjection/Configuration.php
bundles/AdminBundle/EventListener/AdminSecurityListener.php
bundles/AdminBundle/PimcoreAdminBundle.php
bundles/AdminBundle/Resources/config/event_listeners.yaml
bundles/AdminBundle/Resources/config/security_services.yaml
bundles/AdminBundle/Resources/public/js/pimcore/document/pages/preview.js
bundles/AdminBundle/Resources/views/Admin/Index/index.html.twig
bundles/AdminBundle/Security/ContentSecurityPolicyHandler.php
bundles/CoreBundle/Resources/config/pimcore/default.yaml
bundles/EcommerceFrameworkBundle/Resources/views/back-office.html.twig
doc/Development_Documentation/26_Best_Practice/75_Security_Concept.md
lib/Twig/Extension/Templating/HeadScript.php
Commit message: [Admin] Security - Add handler to enable Content Security Policy (#11447)

* [Admin] Security - Add handler to enable Content Security Policy

* [Admin] Security - Add handler to enable Content Security Policy

* [Admin] Security - Add handler to enable Content Security Policy - fix document preview

* [Admin] Security - Add handler to enable Content Security Policy - add docs

* [Admin] Security - Add handler to enable Content Security Policy - review changes

* [Admin] Security - Add handler to enable Content Security Policy - review changes

* [Admin] Security - Add handler to enable Content Security Policy - review changes

* [Admin] Security - Add handler to enable Content Security Policy - review changes

* added missing config node + micro optimizations

Co-authored-by: Bernhard Rusch <bernhard.rusch@elements.at>

Vulnerable code:
            var iframeOnLoad = "pimcore.globalmanager.get('document_" + this.page.id + "').preview.iFrameLoaded()";

                html: '<iframe src="about:blank" onload="' + iframeOnLoad + '" frameborder="0" ' +
                    'name="' + this.iframeName + '"></iframe>'
    <script>
    <script src="{{ asset('bundles/fosjsrouting/js/router.js') }}"></script>
    <script src="{{ path('fos_js_routing_js', {'callback' : 'fos.Router.setData'}) }}"></script>
<script>
<script src="{{ path('pimcore_admin_misc_jsontranslationssystem', {'language': language, '_dc': settings.build }) }}"></script>
<script src="{{ path('pimcore_admin_user_getcurrentuser', {'_dc': settings.build }) }}"></script>
<script src="{{ path('pimcore_admin_misc_availablelanguages', {'_dc': settings.build }) }}"></script>
    <script src="/bundles/pimcoreadmin/js/{{ scriptUrl }}?_dc={{ settings.build }}"></script>
        <script src="{{ pluginJsPath }}?_dc={{ pluginDcValue }}"></script>
<script src="/bundles/pimcoreadmin/js/pimcore/startup.js?_dc={{ settings.build }}"></script>
    <script>

Fixed code:
<?php

declare(strict_types=1);

/**
 * Pimcore
 *
 * This source file is available under two different licenses:
 * - GNU General Public License version 3 (GPLv3)
 * - Pimcore Commercial License (PCL)
 * Full copyright and license information is available in
 * LICENSE.md which is distributed with this source code.
 *
 * @copyright  Copyright (c) Pimcore GmbH (http://www.pimcore.org)
 * @license    http://www.pimcore.org/license     GPLv3 and PCL
 */

namespace Pimcore\Bundle\AdminBundle\DependencyInjection\Compiler;

use Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
 * @internal
 */
final class ContentSecurityPolicyUrlsPass implements CompilerPassInterface
{
    /**
     * {@inheritdoc}
     */
    public function process(ContainerBuilder $container)
    {
        $definition = $container->getDefinition(ContentSecurityPolicyHandler::class);


        $config = $container->getParameter('pimcore_admin.config');

        if (count($config['admin_csp_header']['additional_urls'])) {
            foreach ($config['admin_csp_header']['additional_urls'] as $additionalUrlsKey => $additionalUrlsArr) {
                $definition->addMethodCall('addAllowedUrls', [$additionalUrlsKey, $additionalUrlsArr]);
            }
        }
    }
}
use Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler;
            ->arrayNode('admin_csp_header')
                ->canBeEnabled()
                ->info('Can be used to enable or disable the Content Security Policy headers.')
                ->children()
                    ->arrayNode('additional_urls')
                        ->addDefaultsIfNotSet()
                        ->normalizeKeys(false)
                        ->children()
                            ->arrayNode(ContentSecurityPolicyHandler::DEFAULT_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::IMG_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::SCRIPT_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::STYLE_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::CONNECT_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::FONT_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::MEDIA_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                            ->arrayNode(ContentSecurityPolicyHandler::FRAME_OPT)
                                ->scalarPrototype()->end()
                            ->end()
                        ->end()
                    ->end()
                ->end()
            ->end()
<?php

/**
 * Pimcore
 *
 * This source file is available under two different licenses:
 * - GNU General Public License version 3 (GPLv3)
 * - Pimcore Commercial License (PCL)
 * Full copyright and license information is available in
 * LICENSE.md which is distributed with this source code.
 *
 * @copyright  Copyright (c) Pimcore GmbH (http://www.pimcore.org)
 * @license    http://www.pimcore.org/license     GPLv3 and PCL
 */

namespace Pimcore\Bundle\AdminBundle\EventListener;

use Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler;
use Pimcore\Bundle\CoreBundle\EventListener\Traits\PimcoreContextAwareTrait;
use Pimcore\Config;
use Pimcore\Http\Request\Resolver\PimcoreContextResolver;
use Pimcore\Http\RequestHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * @internal
 */
class AdminSecurityListener implements EventSubscriberInterface
{
    use PimcoreContextAwareTrait;

    /**
     * @param ContentSecurityPolicyHandler $contentSecurityPolicyHandler
     */
    public function __construct(
        protected RequestHelper $requestHelper,
        protected ContentSecurityPolicyHandler $contentSecurityPolicyHandler,
        protected Config $config
    )
    {
    }

    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::RESPONSE => 'onKernelResponse',
        ];
    }

    public function onKernelResponse(ResponseEvent $event)
    {
        if (!$this->config['admin_csp_header']['enabled']) {
            return;
        }

        $request = $event->getRequest();

        if (!$event->isMainRequest()) {
            return;
        }

        if (!$this->matchesPimcoreContext($request, PimcoreContextResolver::CONTEXT_ADMIN)) {
            return;
        }

        if ($this->requestHelper->isFrontendRequestByAdmin($request)) {
            return;
        }

        $response = $event->getResponse();

        // set CSP header with random nonce string to the response
        $response->headers->set("Content-Security-Policy", $this->contentSecurityPolicyHandler->getCspHeader());
    }

}


use Pimcore\Bundle\AdminBundle\DependencyInjection\Compiler\ContentSecurityPolicyUrlsPass;
        $container->addCompilerPass(new ContentSecurityPolicyUrlsPass());
    Pimcore\Bundle\AdminBundle\EventListener\AdminSecurityListener: ~
    Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler:
        public: true
        calls:
            - [ setLogger, [ '@logger' ] ]
        tags:
            - { name: monolog.logger, channel: security }

                html: '<iframe src="about:blank" frameborder="0" ' +
                    'name="' + this.iframeName + '"></iframe>',
                listeners: {
                    afterrender: function () {
                        Ext.get(this.getIframe()).on('load', function () {
                            this.iFrameLoaded();
                        }.bind(this));
                    }.bind(this)
                }
    <script {{ pimcore_csp.getNonceHtmlAttribute()|raw }}>
    <script src="{{ asset('bundles/fosjsrouting/js/router.js') }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
    <script src="{{ path('fos_js_routing_js', {'callback' : 'fos.Router.setData'}) }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
<script {{ pimcore_csp.getNonceHtmlAttribute()|raw }}>
<script src="{{ path('pimcore_admin_misc_jsontranslationssystem', {'language': language, '_dc': settings.build }) }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
<script src="{{ path('pimcore_admin_user_getcurrentuser', {'_dc': settings.build }) }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
<script src="{{ path('pimcore_admin_misc_availablelanguages', {'_dc': settings.build }) }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
    <script src="/bundles/pimcoreadmin/js/{{ scriptUrl }}?_dc={{ settings.build }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
        <script src="{{ pluginJsPath }}?_dc={{ pluginDcValue }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
<script src="/bundles/pimcoreadmin/js/pimcore/startup.js?_dc={{ settings.build }}" {{ pimcore_csp.getNonceHtmlAttribute()|raw }}></script>
<?php

/**
 * Pimcore
 *
 * This source file is available under two different licenses:
 * - GNU General Public License version 3 (GPLv3)
 * - Pimcore Commercial License (PCL)
 * Full copyright and license information is available in
 * LICENSE.md which is distributed with this source code.
 *
 * @copyright  Copyright (c) Pimcore GmbH (http://www.pimcore.org)
 * @license    http://www.pimcore.org/license     GPLv3 and PCL
 */

namespace Pimcore\Bundle\AdminBundle\Security;

use Pimcore\Config;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * @internal
 */
class ContentSecurityPolicyHandler implements LoggerAwareInterface
{
    use LoggerAwareTrait;

    /** @var String|null */
    private ?string $nonce = null;

    public const DEFAULT_OPT = 'default-src';
    public const IMG_OPT = 'img-src';
    public const SCRIPT_OPT = 'script-src';
    public const STYLE_OPT = 'style-src';
    public const CONNECT_OPT = 'connect-src';
    public const FONT_OPT = 'font-src';
    public const MEDIA_OPT = 'media-src';
    public const FRAME_OPT = 'frame-src';

    /**
     * @var array
     */
    private array $allowedUrls = [
        self::CONNECT_OPT => [
            'https://liveupdate.pimcore.org/update-check', //AdminBundle statistics & update-check service
            'https://nominatim.openstreetmap.org/' //CoreBundle geocoding_url_template
        ],
    ];

    public function __construct(protected Config $config, protected array $cspHeaderOptions = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);

        $this->cspHeaderOptions = $resolver->resolve($cspHeaderOptions);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            self::DEFAULT_OPT => "'self'",
            self::IMG_OPT => "* data: blob:",
            self::MEDIA_OPT => "'self' data:",
            self::SCRIPT_OPT => "'self' 'nonce-" . $this->getNonce() . "' 'unsafe-inline' 'unsafe-eval'",
            self::STYLE_OPT => "'self' 'unsafe-inline'",
            self::FRAME_OPT => "'self'",
            self::CONNECT_OPT => "'self' blob:",
            self::FONT_OPT => "'self'",
        ]);
    }

    /**
     * @return string
     */
    public function getCspHeader(): string
    {
        $cspHeaderOptions = array_map(function ($k, $v) {
            return "$k $v " . $this->getAllowedUrls($k);
        }, array_keys($this->cspHeaderOptions), array_values($this->cspHeaderOptions));

        return implode(';' ,$cspHeaderOptions);
    }

    /**
     * @param string $key
     * @param bool $flatten
     *
     * @return array|string
     */
    private function getAllowedUrls(string $key, bool $flatten = true): array|string
    {
        if (!$flatten) {
            return $this->allowedUrls[$key] ?? [];
        }

        return isset($this->allowedUrls[$key]) && is_array($this->allowedUrls[$key]) ? implode(' ', $this->allowedUrls[$key]) : '';
    }

    /**
     * @param string $key
     * @param array $value
     *
     * @return $this
     */
    public function addAllowedUrls(string $key, array $value): self
    {
        if(!isset($this->allowedUrls[$key])) {
            $this->allowedUrls[$key] = [];
        }

        foreach ($value as $val) {
            $this->allowedUrls[$key][] = $val;
        }

        return $this;
    }

    /**
     * @param string $key
     * @param string $value
     *
     * @return $this
     */
    public function setCspHeader(string $key, string $value): self
    {
        $this->cspHeaderOptions[$key] = $value;

        return $this;
    }

    /**
     *
     * @return string
     */
    public function getNonceHtmlAttribute(): string
    {
        return $this->config['admin_csp_header']['enabled'] ? ' nonce="' . $this->getNonce() . '"' : '';
    }

    /**
     * Generates a random nonce parameter.
     *
     * @return string
     */
    private function getNonce(): string
    {
        if (!$this->nonce) {
            $this->nonce = generateRandomSymfonySecret();
        }

        return $this->nonce;
    }
}
        pimcore_csp: '@Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler'
    <script {{ pimcore_csp.getNonceHtmlAttribute()|raw }}>
### Content Security Policy
Pimcore provides a Content Security Policy handler, which enables an additional security layer to protect from certain attacks like Cross-Site Scripting (XSS) and data injection and so on, by adding `Content-Security-Policy` HTTP response header with [nonce](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce) to every request in Admin interface. The generated nonce encoded string is matched with the one provided in link or inline javascript, which allows them to be executed safely. 

Read more about [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP).

To enable CSP, add a custom configuration in `config/config.yaml`:
```yaml
# config/config.yaml
pimcore_admin:
    admin_csp_header:
        enabled: true
```

And to allow external urls for each directive, you can provide a list in the configuration:
```yaml
# config/config.yaml
pimcore_admin:
    admin_csp_header:
        additional_urls:
            script_src:
                - 'https://oreo.cat/scripts/meow.js' 
                - 'https://bagheera.cat/*'
            style-src:
                - 'https://oreo.cat/scripts/meow.css'
```

In case, you are using third party bundles or custom implementation that extends the admin backend interface with custom views then you would need to use generated nonce string in your scripts.
If a script does not contain valid a nonce, then it is stopped from being executed wih a warning in console like:

`Refused to execute inline script because it violates the following Content Security Policy directive: ...`

This issue can be resolved either by using Pimcore [Headscript extension](../02_MVC/02_Template/02_Template_Extensions/03_HeadScript.md) or add nonce script to inline scripts as follows:

```twig
<script {{ pimcore_csp.getNonceHtmlAttribute()|raw }}>
```
use Pimcore\Bundle\AdminBundle\Security\ContentSecurityPolicyHandler;
        /** @var ContentSecurityPolicyHandler $cspHandler */
        $cspHandler = \Pimcore::getContainer()->get(ContentSecurityPolicyHandler::class);
        $attrString .= $cspHandler->getNonceHtmlAttribute();