Skip to content

Commit

Permalink
NEXT-9243 - Add more security headers
Browse files Browse the repository at this point in the history
- generalise csp handling
- Fixes NEXT-9176
  • Loading branch information
pweyck committed Jun 30, 2020
1 parent 5d2a32c commit c4d2a49
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 33 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG-6.2.md
Expand Up @@ -38,6 +38,11 @@ To get the diff between two versions, go to https://github.com/shopware/platform
* Added configuration `media.enable_url_upload_feature` in `shopware.yaml` to disable the "Upload media via URL" feature
* Added configuration `media.enable_url_validation` in `shopware.yaml` to disable the URL validation when a media is uploaded via URL
* Added decoratable class `Shopware\Core\Content\Media\File\FileUrlValidator`
* Added the following headers to improve security:
* `Strict-Transport-Security: max-age=31536000; includeSubDomains` if the request is secure (HTTPS)
* `X-Frame-Options: deny`
* `X-Content-Type-Options: nosniff`
* `Content-Security-Policy: script-src 'none'; object-src 'none'; base-uri 'self';` default for requests with route scope other than `administration` or `storefront`

* Storefront
* Added block `component_offcanvas_cart_header_item_counter` to `src/Storefront/Resources/views/storefront/component/checkout/offcanvas-cart.html.twig`
Expand Down
22 changes: 4 additions & 18 deletions src/Administration/Controller/AdministrationController.php
Expand Up @@ -8,6 +8,7 @@
use Shopware\Core\Framework\FeatureFlag\FeatureConfig;
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
use Shopware\Core\Framework\Store\Services\FirstRunWizardClient;
use Shopware\Core\PlatformRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -33,23 +34,16 @@ class AdministrationController extends AbstractController

private $supportedApiVersions;

/**
* @var string|null
*/
private $cspHeaderTemplate;

public function __construct(
TemplateFinder $finder,
FirstRunWizardClient $firstRunWizardClient,
SnippetFinderInterface $snippetFinder,
$supportedApiVersions,
?string $cspHeaderTemplate = null
$supportedApiVersions
) {
$this->finder = $finder;
$this->firstRunWizardClient = $firstRunWizardClient;
$this->snippetFinder = $snippetFinder;
$this->supportedApiVersions = $supportedApiVersions;
$this->cspHeaderTemplate = $cspHeaderTemplate;
}

/**
Expand All @@ -59,25 +53,17 @@ public function __construct(
public function index(Request $request): Response
{
$template = $this->finder->find('@Administration/administration/index.html.twig');
$nonce = base64_encode(random_bytes(8));

$response = $this->render($template, [
return $this->render($template, [
'features' => FeatureConfig::getAll(),
'systemLanguageId' => Defaults::LANGUAGE_SYSTEM,
'defaultLanguageIds' => [Defaults::LANGUAGE_SYSTEM],
'systemCurrencyId' => Defaults::CURRENCY,
'liveVersionId' => Defaults::LIVE_VERSION,
'firstRunWizard' => $this->firstRunWizardClient->frwShouldRun(),
'apiVersion' => $this->getLatestApiVersion(),
'cspNonce' => $nonce,
'cspNonce' => $request->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE),
]);

if ($this->cspHeaderTemplate !== null) {
$csp = str_replace('%nonce%', $nonce, $this->cspHeaderTemplate);
$response->headers->set('Content-Security-Policy', $csp);
}

return $response;
}

/**
Expand Down
9 changes: 0 additions & 9 deletions src/Administration/DependencyInjection/services.xml
Expand Up @@ -3,14 +3,6 @@
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<parameters>
<parameter key="admin_csp_template" type="string">
object-src 'none';
script-src 'strict-dynamic' 'nonce-%%nonce%%' 'unsafe-inline' 'unsafe-eval' https: http:;
base-uri 'self';
</parameter>
</parameters>
<services>
<service id="Shopware\Administration\Command\AdministrationDumpFeaturesCommand">
<argument type="service" id="kernel"/>
Expand All @@ -27,7 +19,6 @@
<call method="setContainer">
<argument type="service" id="service_container"/>
</call>
<argument type="string">%admin_csp_template%</argument>
</service>

<service id="Shopware\Administration\Framework\Routing\AdministrationRouteScope">
Expand Down
32 changes: 29 additions & 3 deletions src/Core/Framework/Api/Controller/InfoController.php
Expand Up @@ -10,10 +10,12 @@
use Shopware\Core\Framework\Event\BusinessEventRegistry;
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
use Shopware\Core\Kernel;
use Shopware\Core\PlatformRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Asset\Packages;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

Expand Down Expand Up @@ -52,20 +54,27 @@ class InfoController extends AbstractController
*/
private $enableUrlFeature;

/**
* @var array
*/
private $cspTemplates;

public function __construct(
DefinitionService $definitionService,
ParameterBagInterface $params,
BusinessEventRegistry $actionEventRegistry,
Kernel $kernel,
Packages $packages,
bool $enableUrlFeature = true
bool $enableUrlFeature = true,
array $cspTemplates = []
) {
$this->definitionService = $definitionService;
$this->params = $params;
$this->actionEventRegistry = $actionEventRegistry;
$this->packages = $packages;
$this->kernel = $kernel;
$this->enableUrlFeature = $enableUrlFeature;
$this->cspTemplates = $cspTemplates;
}

/**
Expand Down Expand Up @@ -103,9 +112,26 @@ public function entitySchema(int $version): JsonResponse
/**
* @Route("/api/v{version}/_info/swagger.html", defaults={"auth_required"="%shopware.api.api_browser.auth_required_str%"}, name="api.info.swagger", methods={"GET"})
*/
public function infoHtml(int $version): Response
public function infoHtml(Request $request, int $version): Response
{
return $this->render('@Framework/swagger.html.twig', ['schemaUrl' => 'api.info.openapi3', 'apiVersion' => $version]);
$nonce = $request->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE);
$response = $this->render(
'@Framework/swagger.html.twig',
[
'schemaUrl' => 'api.info.openapi3',
'apiVersion' => $version,
'cspNonce' => $nonce,
]
);

$cspTemplate = $this->cspTemplates['administration'] ?? '';
$cspTemplate = trim($cspTemplate);
if ($cspTemplate !== '') {
$csp = str_replace('%nonce%', $nonce, $cspTemplate);
$response->headers->set('Content-Security-Policy', $csp);
}

return $response;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/Core/Framework/DependencyInjection/api.xml
Expand Up @@ -194,6 +194,7 @@
<argument type="service" id="kernel" />
<argument type="service" id="assets.packages" />
<argument>%shopware.media.enable_url_upload_feature%</argument>
<argument>%shopware.security.csp_templates%</argument>
<call method="setContainer">
<argument type="service" id="service_container"/>
</call>
Expand Down
19 changes: 19 additions & 0 deletions src/Core/Framework/DependencyInjection/services.xml
Expand Up @@ -17,6 +17,20 @@
<!-- Migration config -->
<parameter key="core.migration.directories" type="collection" />
<parameter key="migration.active" type="collection"/>

<parameter key="shopware.security.csp_templates" type="collection">
<parameter key="default">
object-src 'none';
script-src 'none';
base-uri 'self';
</parameter>
<parameter key="administration">
object-src 'none';
script-src 'strict-dynamic' 'nonce-%%nonce%%' 'unsafe-inline' 'unsafe-eval' https: http:;
base-uri 'self';
</parameter>
<parameter key="storefront" />
</parameter>
</parameters>

<monolog:config>
Expand Down Expand Up @@ -159,6 +173,11 @@
<tag name="kernel.event_subscriber"/>
</service>

<service id="Shopware\Core\Framework\Routing\CoreSubscriber">
<argument>%shopware.security.csp_templates%</argument>
<tag name="kernel.event_subscriber"/>
</service>

<service id="Shopware\Core\Framework\Routing\SymfonyRouteScopeWhitelist">
<tag name="shopware.route_scope_whitelist"/>
</service>
Expand Down
6 changes: 3 additions & 3 deletions src/Core/Framework/Resources/views/swagger.html.twig
Expand Up @@ -35,9 +35,9 @@
<body>
<div id="swagger-ui"></div>

<script src="{{ asset('bundles/framework/swagger-ui-bundle.js') }}"> </script>
<script src="{{ asset('bundles/framework/swagger-ui-standalone-preset.js') }}"> </script>
<script>
<script nonce="{{ cspNonce }}" src="{{ asset('bundles/framework/swagger-ui-bundle.js') }}"> </script>
<script nonce="{{ cspNonce }}" src="{{ asset('bundles/framework/swagger-ui-standalone-preset.js') }}"> </script>
<script nonce="{{ cspNonce }}" >
window.onload = function() {
// Build a system
Expand Down
66 changes: 66 additions & 0 deletions src/Core/Framework/Routing/CoreSubscriber.php
@@ -0,0 +1,66 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Routing;

use Shopware\Core\PlatformRequest;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class CoreSubscriber implements EventSubscriberInterface
{
/**
* @var string[]
*/
private $cspTemplates;

public function __construct($cspTemplates)
{
$this->cspTemplates = (array) $cspTemplates;
}

public static function getSubscribedEvents()
{
return [
KernelEvents::REQUEST => 'initializeCspNonce',
KernelEvents::RESPONSE => 'setSecurityHeaders',
];
}

public function initializeCspNonce(RequestEvent $event): void
{
$nonce = base64_encode(random_bytes(8));
$event->getRequest()->attributes->set(PlatformRequest::ATTRIBUTE_CSP_NONCE, $nonce);
}

public function setSecurityHeaders(ResponseEvent $event): void
{
if (!$event->getResponse()->isSuccessful()) {
return;
}

$response = $event->getResponse();
if ($event->getRequest()->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
$response->headers->set('X-Frame-Options', 'deny');
$response->headers->set('X-Content-Type-Options', 'nosniff');

$cspTemplate = $this->cspTemplates['default'] ?? '';

$scope = $event->getRequest()->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE);
if ($scope) {
foreach ($scope->getScopes() as $scope) {
$cspTemplate = $this->cspTemplates[$scope] ?? $cspTemplate;
}
}

$cspTemplate = trim($cspTemplate);
if ($cspTemplate !== '' && !$response->headers->has('Content-Security-Policy')) {
$nonce = $event->getRequest()->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE);
$csp = str_replace('%nonce%', $nonce, $cspTemplate);
$response->headers->set('Content-Security-Policy', $csp);
}
}
}
100 changes: 100 additions & 0 deletions src/Core/Framework/Test/Routing/CoreSubscriberTest.php
@@ -0,0 +1,100 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Test\Routing;

use PHPUnit\Framework\TestCase;
use Shopware\Core\Framework\Test\TestCaseBase\AdminApiTestBehaviour;
use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
use Shopware\Core\PlatformRequest;

class CoreSubscriberTest extends TestCase
{
use IntegrationTestBehaviour;
use AdminApiTestBehaviour;

public function testDefaultHeadersHttp(): void
{
$browser = $this->getBrowser();
$v = $this->getContainer()->getParameter('kernel.supported_api_versions')[0];

$browser->request('GET', '/api/v' . $v . '/category');
$response = $browser->getResponse();

static::assertTrue($response->headers->has('X-Frame-Options'));
static::assertTrue($response->headers->has('X-Content-Type-Options'));
static::assertTrue($response->headers->has('Content-Security-Policy'));

static::assertFalse($response->headers->has('Strict-Transport-Security'));
}

public function testDefaultHeadersHttps(): void
{
$browser = $this->getBrowser();
$browser->setServerParameter('HTTPS', true);
$v = $this->getContainer()->getParameter('kernel.supported_api_versions')[0];

$browser->request('GET', '/api/v' . $v . '/category');
$response = $browser->getResponse();

static::assertTrue($response->headers->has('X-Frame-Options'));
static::assertTrue($response->headers->has('X-Content-Type-Options'));
static::assertTrue($response->headers->has('Content-Security-Policy'));

static::assertTrue($response->headers->has('Strict-Transport-Security'));
}

public function testStorefrontNoCsp(): void
{
$browser = $this->getBrowser();
$browser->request('GET', $_SERVER['APP_URL']);
$response = $browser->getResponse();

static::assertTrue($response->headers->has('X-Frame-Options'));
static::assertTrue($response->headers->has('X-Content-Type-Options'));
static::assertFalse($response->headers->has('Content-Security-Policy'));
}

public function testAdminHasCsp(): void
{
$browser = $this->getBrowser();
$browser->request('GET', $_SERVER['APP_URL'] . '/admin');
$response = $browser->getResponse();

static::assertTrue($response->headers->has('X-Frame-Options'));
static::assertTrue($response->headers->has('X-Content-Type-Options'));
static::assertTrue($response->headers->has('Content-Security-Policy'));

$request = $browser->getRequest();
static::assertTrue($request->attributes->has(PlatformRequest::ATTRIBUTE_CSP_NONCE));
$nonce = $request->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE);

static::assertRegExp(
'/.*script-src[^;]+nonce-' . preg_quote($nonce, '/') . '.*/',
$response->headers->get('Content-Security-Policy'),
'CSP should contain the nonce'
);
}

public function testSwaggerHasCsp(): void
{
$browser = $this->getBrowser();
$v = $this->getContainer()->getParameter('kernel.supported_api_versions')[0];

$browser->request('GET', '/api/v' . $v . '/_info/swagger.html');
$response = $browser->getResponse();

static::assertTrue($response->headers->has('X-Frame-Options'));
static::assertTrue($response->headers->has('X-Content-Type-Options'));
static::assertTrue($response->headers->has('Content-Security-Policy'));

$request = $browser->getRequest();
static::assertTrue($request->attributes->has(PlatformRequest::ATTRIBUTE_CSP_NONCE));
$nonce = $request->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE);

static::assertRegExp(
'/.*script-src[^;]+nonce-' . preg_quote($nonce, '/') . '.*/',
$response->headers->get('Content-Security-Policy'),
'CSP should contain the nonce'
);
}
}

0 comments on commit c4d2a49

Please sign in to comment.