Merge branch '1.11'

* 1.11:
  [Security] XSS - SVG file upload vulnerability fixed
  [PHPUnit] Move subscribers tests to main directory
  Replace str_contains with strpos method to support PHP 7
  [Behat] Extract browser element and context
  [Behat] Add scenarios for securing access to account and dashboard after logging out
  Minor fixes for specs and unit tests of cache control subscribers
  Remove type declarations for properties due to supporting PHP 7.3
  [Maintenace] Test existence of new cache headers
  [UI] Force no-store cache directives for admin and customer account section
  suggested review changes
  listener added to finish response with X-Frame-Options sameorigin header
This commit is contained in:
Mateusz Zalewski 2022-03-14 15:10:13 +01:00
commit 1cb6209a65
No known key found for this signature in database
GPG key ID: 9BECA0BB71612E52
34 changed files with 764 additions and 8 deletions

View file

@ -43,6 +43,7 @@
"doctrine/orm": "^2.7",
"doctrine/persistence": "^2.3",
"egulias/email-validator": "^3.1",
"enshrined/svg-sanitize": "^0.15.4",
"fakerphp/faker": "^1.9",
"friendsofphp/proxy-manager-lts": "^1.0",
"friendsofsymfony/rest-bundle": "^3.0",

View file

@ -0,0 +1,17 @@
@customer_account
Feature: Securing access to the account after using the back button after logging out
In order to have my personal information secured
As a Customer
I want to be unable to access to the account by using the back button after logging out
Background:
Given the store operates on a single channel in "United States"
And I am a logged in customer
And I am browsing my orders
@ui @javascript @no-api
Scenario: Securing access to the account after using the back button after logging out
When I log out
And I go back one page in the browser
Then I should not see my orders
And I should be on the login page

View file

@ -0,0 +1,17 @@
@admin_dashboard
Feature: Securing access to the administration panel after using the back button after logging out
In order to have administration panel secured
As an Administrator
I want to be unable to access to the administration panel by using the back button after logging out
Background:
Given the store operates on a single channel in "United States"
And I am logged in as an administrator
And I am on the administration dashboard
@ui @javascript @no-api
Scenario: Securing access to administration dashboard after using the back button after logging out
When I log out
And I go back one page in the browser
Then I should not see the administration dashboard
And I should be on the login page

View file

@ -29,9 +29,10 @@ final class DashboardContext implements Context
}
/**
* @Given I am on the administration dashboard
* @When I (try to )open administration dashboard
*/
public function iOpenAdministrationDashboard()
public function iOpenAdministrationDashboard(): void
{
try {
$this->dashboardPage->open();
@ -55,6 +56,14 @@ final class DashboardContext implements Context
$this->dashboardPage->chooseChannel($channelName);
}
/**
* @When I log out
*/
public function iLogOut(): void
{
$this->dashboardPage->logOut();
}
/**
* @Then I should see :number new orders
*/
@ -102,4 +111,12 @@ final class DashboardContext implements Context
{
Assert::same($this->dashboardPage->getNumberOfNewOrdersInTheList(), (int) $number);
}
/**
* @Then I should not see the administration dashboard
*/
public function iShouldNotSeeTheAdministrationDashboard(): void
{
Assert::false($this->dashboardPage->isOpen());
}
}

View file

@ -139,4 +139,12 @@ final class LoginContext implements Context
$this->loginPage->specifyPassword($password);
$this->loginPage->logIn();
}
/**
* @Then I should be on the login page
*/
public function iShouldBeOnTheLoginPage(): void
{
Assert::true($this->loginPage->isOpen());
}
}

View file

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Context\Ui;
use Behat\Behat\Context\Context;
use Sylius\Behat\Element\BrowserElementInterface;
final class BrowserContext implements Context
{
/** @var BrowserElementInterface */
private $browserElement;
public function __construct(BrowserElementInterface $browserElement)
{
$this->browserElement = $browserElement;
}
/**
* @When I go back one page in the browser
*/
public function iGoBackOnePageInTheBrowser(): void
{
$this->browserElement->goBack();
}
}

View file

@ -255,9 +255,10 @@ final class AccountContext implements Context
}
/**
* @Given I am browsing my orders
* @When I browse my orders
*/
public function iBrowseMyOrders()
public function iBrowseMyOrders(): void
{
$this->orderIndexPage->open();
}
@ -523,4 +524,20 @@ final class AccountContext implements Context
throw new \InvalidArgumentException('Dashboard has been openned, but it shouldn\'t as customer should not be logged in');
}
/**
* @Then I should not see my orders
*/
public function iShouldNotSeeMyOrders(): void
{
Assert::false($this->orderIndexPage->isOpen());
}
/**
* @Then I should be on the login page
*/
public function iShouldBeOnTheLoginPage(): void
{
Assert::true($this->loginPage->isOpen());
}
}

View file

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Element;
use FriendsOfBehat\PageObjectExtension\Element\Element;
final class BrowserElement extends Element implements BrowserElementInterface
{
public function goBack(): void
{
$this->getDriver()->back();
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Element;
interface BrowserElementInterface
{
public function goBack(): void;
}

View file

@ -317,6 +317,10 @@
<tag name="fob.context_service" />
</service>
<service id="sylius.behat.context.ui.browser" class="Sylius\Behat\Context\Ui\BrowserContext">
<argument type="service" id="sylius.behat.element.browser" />
</service>
<service id="sylius.behat.context.ui.channel" class="Sylius\Behat\Context\Ui\ChannelContext">
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.behat.channel_context_setter" />

View file

@ -20,10 +20,13 @@
<import resource="elements/shop.xml" />
<import resource="elements/product.xml" />
</imports>
<services>
<service id="sylius.behat.element" class="FriendsOfBehat\PageObjectExtension\Element\Element" abstract="true" public="false">
<argument type="service" id="behat.mink.default_session" />
<argument type="service" id="behat.mink.parameters" />
</service>
<service id="sylius.behat.element.browser" class="Sylius\Behat\Element\BrowserElement" parent="sylius.behat.element" public="false" />
</services>
</container>

View file

@ -33,6 +33,7 @@ default:
- sylius.behat.context.setup.user
- sylius.behat.context.setup.zone
- sylius.behat.context.ui.browser
- sylius.behat.context.ui.channel
- sylius.behat.context.ui.email
- sylius.behat.context.ui.shop.account
@ -44,6 +45,7 @@ default:
- sylius.behat.context.ui.shop.checkout.shipping
- sylius.behat.context.ui.shop.currency
- sylius.behat.context.ui.shop.homepage
- sylius.behat.context.ui.user
filters:
tags: "@customer_account&&@ui"

View file

@ -26,7 +26,9 @@ default:
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.ui.admin.dashboard
- sylius.behat.context.ui.admin.login
- sylius.behat.context.ui.admin.notification
- sylius.behat.context.ui.browser
filters:
tags: "@admin_dashboard&&@ui"

View file

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\AdminBundle\EventListener;
use Sylius\Bundle\AdminBundle\SectionResolver\AdminSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class AdminSectionCacheControlSubscriber implements EventSubscriberInterface
{
/** @var SectionProviderInterface */
private $sectionProvider;
public function __construct(SectionProviderInterface $sectionProvider)
{
$this->sectionProvider = $sectionProvider;
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'setCacheControlDirectives',
];
}
public function setCacheControlDirectives(ResponseEvent $event): void
{
if (!$this->sectionProvider->getSection() instanceof AdminSection) {
return;
}
$response = $event->getResponse();
$response->headers->addCacheControlDirective('no-cache', true);
$response->headers->addCacheControlDirective('max-age', '0');
$response->headers->addCacheControlDirective('must-revalidate', true);
$response->headers->addCacheControlDirective('no-store', true);
}
}

View file

@ -25,5 +25,10 @@
<argument type="service" id="session" />
<tag name="kernel.event_subscriber" event="kernel.exception" />
</service>
<service id="sylius.event_subscriber.admin_cache_control_subscriber" class="Sylius\Bundle\AdminBundle\EventListener\AdminSectionCacheControlSubscriber">
<argument type="service" id="sylius.section_resolver.uri_based_section_resolver" />
<tag name="kernel.event_subscriber" event="kernel.response" />
</service>
</services>
</container>

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\AdminBundle\EventListener;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\AdminBundle\SectionResolver\AdminSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionInterface;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
final class AdminSectionCacheControlSubscriberSpec extends ObjectBehavior
{
function let(SectionProviderInterface $sectionProvider): void
{
$this->beConstructedWith($sectionProvider);
}
function it_subscribes_to_kernel_response_event(): void
{
$this::getSubscribedEvents()->shouldReturn([KernelEvents::RESPONSE => 'setCacheControlDirectives']);
}
function it_adds_cache_control_directives_to_admin_requests(
SectionProviderInterface $sectionProvider,
HttpKernelInterface $kernel,
Request $request,
Response $response,
ResponseHeaderBag $responseHeaderBag,
AdminSection $adminSection
): void {
$sectionProvider->getSection()->willReturn($adminSection);
$response->headers = $responseHeaderBag->getWrappedObject();
$event = new ResponseEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
KernelInterface::MASTER_REQUEST,
$response->getWrappedObject()
);
$responseHeaderBag->addCacheControlDirective('no-cache', true)->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('max-age', '0')->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('must-revalidate', true)->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('no-store', true)->shouldBeCalled();
$this->setCacheControlDirectives($event);
}
function it_does_nothing_if_section_is_different_than_admin(
SectionProviderInterface $sectionProvider,
HttpKernelInterface $kernel,
Request $request,
Response $response,
ResponseHeaderBag $responseHeaderBag,
SectionInterface $section
): void {
$sectionProvider->getSection()->willReturn($section);
$response->headers = $responseHeaderBag->getWrappedObject();
$event = new ResponseEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
KernelInterface::MASTER_REQUEST,
$response->getWrappedObject()
);
$responseHeaderBag->addCacheControlDirective('no-cache', true)->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('max-age', '0')->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('must-revalidate', true)->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('no-store', true)->shouldNotBeCalled();
$this->setCacheControlDirectives($event);
}
}

View file

@ -26,6 +26,7 @@
"php": "^8.0",
"doctrine/dbal": "^2.7",
"api-platform/core": "^2.6",
"enshrined/svg-sanitize": "^0.15.4",
"lexik/jwt-authentication-bundle": "^2.6",
"sylius/core-bundle": "^1.7",
"symfony/messenger": "^5.4"

View file

@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class XFrameOptionsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'onKernelResponse',
];
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$this->isMainRequest($event)) {
return;
}
$response = $event->getResponse();
$response->headers->set('X-Frame-Options', 'sameorigin');
}
private function isMainRequest(ResponseEvent $event): bool
{
if (\method_exists($event, 'isMainRequest')) {
return $event->isMainRequest();
}
return $event->isMasterRequest();
}
}

View file

@ -82,6 +82,10 @@
<argument type="service" id="Sylius\Bundle\CoreBundle\EventListener\LocaleAwareListener.inner" />
</service>
<service id="Sylius\Bundle\CoreBundle\EventListener\XFrameOptionsSubscriber">
<tag name="kernel.event_subscriber" />
</service>
<service id="sylius.listener.taxon_deletion" class="Sylius\Bundle\CoreBundle\EventListener\TaxonDeletionListener">
<argument type="service" id="session" />
<argument type="service" id="sylius.repository.channel" />

View file

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ShopBundle\EventListener;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Bundle\ShopBundle\SectionResolver\ShopCustomerAccountSubSection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ShopCustomerAccountSubSectionCacheControlSubscriber implements EventSubscriberInterface
{
/** @var SectionProviderInterface */
private $sectionProvider;
public function __construct(SectionProviderInterface $sectionProvider)
{
$this->sectionProvider = $sectionProvider;
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'setCacheControlDirectives',
];
}
public function setCacheControlDirectives(ResponseEvent $event): void
{
if (!$this->sectionProvider->getSection() instanceof ShopCustomerAccountSubSection) {
return;
}
$response = $event->getResponse();
$response->headers->addCacheControlDirective('no-cache', true);
$response->headers->addCacheControlDirective('max-age', '0');
$response->headers->addCacheControlDirective('must-revalidate', true);
$response->headers->addCacheControlDirective('no-store', true);
}
}

View file

@ -30,6 +30,7 @@
</service>
<service id="sylius.section_resolver.shop_uri_based_section_resolver" class="Sylius\Bundle\ShopBundle\SectionResolver\ShopUriBasedSectionResolver">
<argument>account</argument>
<tag name="sylius.uri_based_section_resolver" priority="-10" />
</service>

View file

@ -32,6 +32,11 @@
<tag name="kernel.event_listener" event="sylius.customer.post_update" method="sendVerificationEmail" />
</service>
<service id="sylius.listener.shop_customer_account_sub_section_cache_control_subscriber" class="Sylius\Bundle\ShopBundle\EventListener\ShopCustomerAccountSubSectionCacheControlSubscriber">
<argument type="service" id="sylius.section_resolver.uri_based_section_resolver" />
<tag name="kernel.event_subscriber" event="kernel.response" />
</service>
<service id="sylius.listener.order_customer_ip" class="Sylius\Bundle\ShopBundle\EventListener\OrderCustomerIpListener">
<argument type="service" id="sylius.customer_ip_assigner" />
<argument type="service" id="request_stack" />

View file

@ -0,0 +1,18 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ShopBundle\SectionResolver;
class ShopCustomerAccountSubSection extends ShopSection
{
}

View file

@ -18,8 +18,20 @@ use Sylius\Bundle\CoreBundle\SectionResolver\UriBasedSectionResolverInterface;
final class ShopUriBasedSectionResolver implements UriBasedSectionResolverInterface
{
/** @var string */
private $shopCustomerAccountUri;
public function __construct(string $shopCustomerAccountUri = 'account')
{
$this->shopCustomerAccountUri = $shopCustomerAccountUri;
}
public function getSection(string $uri): SectionInterface
{
if (strpos($uri, $this->shopCustomerAccountUri) !== false) {
return new ShopCustomerAccountSubSection();
}
return new ShopSection();
}
}

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ShopBundle\EventListener;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionInterface;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Bundle\ShopBundle\SectionResolver\ShopCustomerAccountSubSection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
final class ShopCustomerAccountSubSectionCacheControlSubscriberSpec extends ObjectBehavior
{
function let(SectionProviderInterface $sectionProvider): void
{
$this->beConstructedWith($sectionProvider);
}
function it_subscribes_to_kernel_response_event()
{
$this::getSubscribedEvents()->shouldReturn([KernelEvents::RESPONSE => 'setCacheControlDirectives']);
}
function it_adds_cache_control_directives_to_customer_account_requests(
SectionProviderInterface $sectionProvider,
HttpKernelInterface $kernel,
Request $request,
Response $response,
ResponseHeaderBag $responseHeaderBag,
ShopCustomerAccountSubSection $customerAccountSubSection
): void {
$sectionProvider->getSection()->willReturn($customerAccountSubSection);
$response->headers = $responseHeaderBag->getWrappedObject();
$event = new ResponseEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
KernelInterface::MASTER_REQUEST,
$response->getWrappedObject()
);
$responseHeaderBag->addCacheControlDirective('no-cache', true)->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('max-age', '0')->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('must-revalidate', true)->shouldBeCalled();
$responseHeaderBag->addCacheControlDirective('no-store', true)->shouldBeCalled();
$this->setCacheControlDirectives($event);
}
function it_does_nothing_if_section_is_different_than_customer_account(
SectionProviderInterface $sectionProvider,
HttpKernelInterface $kernel,
Request $request,
Response $response,
ResponseHeaderBag $responseHeaderBag,
SectionInterface $section
): void {
$sectionProvider->getSection()->willReturn($section);
$response->headers = $responseHeaderBag->getWrappedObject();
$event = new ResponseEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
KernelInterface::MASTER_REQUEST,
$response->getWrappedObject()
);
$responseHeaderBag->addCacheControlDirective('no-cache', true)->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('max-age', '0')->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('must-revalidate', true)->shouldNotBeCalled();
$responseHeaderBag->addCacheControlDirective('no-store', true)->shouldNotBeCalled();
$this->setCacheControlDirectives($event);
}
}

View file

@ -15,6 +15,7 @@ namespace spec\Sylius\Bundle\ShopBundle\SectionResolver;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\CoreBundle\SectionResolver\UriBasedSectionResolverInterface;
use Sylius\Bundle\ShopBundle\SectionResolver\ShopCustomerAccountSubSection;
use Sylius\Bundle\ShopBundle\SectionResolver\ShopSection;
final class ShopUriBasedSectionResolverSpec extends ObjectBehavior
@ -24,7 +25,7 @@ final class ShopUriBasedSectionResolverSpec extends ObjectBehavior
$this->shouldImplement(UriBasedSectionResolverInterface::class);
}
function it_always_returns_shop(): void
function it_returns_shop_by_default(): void
{
$this->getSection('/api/something')->shouldBeLike(new ShopSection());
$this->getSection('/api')->shouldBeLike(new ShopSection());
@ -33,4 +34,29 @@ final class ShopUriBasedSectionResolverSpec extends ObjectBehavior
$this->getSection('/admin/asd')->shouldBeLike(new ShopSection());
$this->getSection('/en_US/api')->shouldBeLike(new ShopSection());
}
function it_uses_account_prefix_for_customer_account_subsection_by_default(): void
{
$this->getSection('/account')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/api/account')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/en_US/account')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/account/random')->shouldBeLike(new ShopCustomerAccountSubSection());
}
function it_may_have_account_prefix_customized(): void
{
$this->beConstructedWith('konto');
$this->getSection('/konto')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/konto')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/api/konto')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/en_US/konto')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/konto/random')->shouldBeLike(new ShopCustomerAccountSubSection());
$this->getSection('/account')->shouldBeLike(new ShopSection());
$this->getSection('/account')->shouldBeLike(new ShopSection());
$this->getSection('/api/account')->shouldBeLike(new ShopSection());
$this->getSection('/en_US/account')->shouldBeLike(new ShopSection());
$this->getSection('/account/random')->shouldBeLike(new ShopSection());
}
}

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Component\Core\Uploader;
use enshrined\svgSanitize\Sanitizer;
use Gaufrette\Filesystem;
use Sylius\Component\Core\Generator\ImagePathGeneratorInterface;
use Sylius\Component\Core\Generator\UploadedImagePathGenerator;
@ -22,9 +23,15 @@ use Webmozart\Assert\Assert;
class ImageUploader implements ImageUploaderInterface
{
private const MIME_SVG_XML = 'image/svg+xml';
private const MIME_SVG = 'image/svg';
/** @var ImagePathGeneratorInterface */
protected $imagePathGenerator;
/** @var Sanitizer */
protected $sanitizer;
public function __construct(
protected Filesystem $filesystem,
?ImagePathGeneratorInterface $imagePathGenerator = null
@ -37,6 +44,7 @@ class ImageUploader implements ImageUploaderInterface
}
$this->imagePathGenerator = $imagePathGenerator ?? new UploadedImagePathGenerator();
$this->sanitizer = new Sanitizer();
}
public function upload(ImageInterface $image): void
@ -45,11 +53,13 @@ class ImageUploader implements ImageUploaderInterface
return;
}
/** @var File $file */
$file = $image->getFile();
/** @var File $file */
Assert::isInstanceOf($file, File::class);
$fileContent = $this->sanitizeContent(file_get_contents($file->getPathname()), $file->getMimeType());
if (null !== $image->getPath() && $this->has($image->getPath())) {
$this->remove($image->getPath());
}
@ -60,10 +70,7 @@ class ImageUploader implements ImageUploaderInterface
$image->setPath($path);
$this->filesystem->write(
$image->getPath(),
file_get_contents($image->getFile()->getPathname())
);
$this->filesystem->write($image->getPath(), $fileContent);
}
public function remove(string $path): bool
@ -75,6 +82,15 @@ class ImageUploader implements ImageUploaderInterface
return false;
}
protected function sanitizeContent(string $fileContent, string $mimeType): string
{
if (self::MIME_SVG_XML === $mimeType || self::MIME_SVG === $mimeType) {
$fileContent = $this->sanitizer->sanitize($fileContent);
}
return $fileContent;
}
private function has(string $path): bool
{
return $this->filesystem->has($path);

View file

@ -27,6 +27,7 @@
],
"require": {
"php": "^8.0",
"enshrined/svg-sanitize": "^0.15.4",
"knplabs/gaufrette": "^0.8",
"payum/payum": "^1.6",
"php-http/guzzle6-adapter": "^2.0",

View file

@ -155,6 +155,9 @@
"egulias/email-validator": {
"version": "2.1.19"
},
"enshrined/svg-sanitize": {
"version": "0.15.4"
},
"fakerphp/faker": {
"version": "v1.12.0"
},

View file

@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\Controller;
use ApiTestCase\JsonApiTestCase;
final class XFrameOptionsTest extends JsonApiTestCase
{
/** @test */
public function it_sets_frame_options_header(): void
{
$this->client->request('GET', '/');
$response = $this->client->getResponse();
$this->assertSame('sameorigin', $response->headers->get('X-Frame-Options'));
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\EventListener;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class AdminSectionCacheControlSubscriberTest extends WebTestCase
{
/**
* @test
*/
public function it_returns_proper_cache_headers_for_admin_endpoints(): void
{
$client = static::createClient();
$client->request('GET', '/admin/');
$this->assertResponseHeaderSame('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store, private');
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\EventListener;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ShopCustomerAccountSubSectionCacheControlSubscriberTest extends WebTestCase
{
/**
* @test
*/
public function it_returns_proper_cache_headers_for_customer_account_endpoints(): void
{
$client = static::createClient();
$client->request('GET', '/en_US/account/');
$this->assertResponseHeaderSame('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store, private');
}
}

View file

@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\Functional;
use PHPUnit\Framework\Assert;
use Sylius\Component\Core\Model\ProductImage;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\BrowserKit\Client;
final class ImageUploaderTest extends WebTestCase
{
/** @var Client */
private static $client;
/** @test */
public function it_sanitizes_file_content_if_it_is_svg_mime_type(): void
{
self::$client = static::createClient();
self::$container = self::$client->getContainer();
$imageUploader = self::$container->get('sylius.image_uploader');
$fileSystem = self::$container->get('gaufrette.sylius_image_filesystem');
$file = new UploadedFile(__DIR__ . '/../Resources/xss.svg', 'xss.svg');
Assert::assertStringContainsString('<script', $this->getContent($file));
$image = new ProductImage();
$image->setFile($file);
$imageUploader->upload($image);
$sanitizedFile = $fileSystem->get($image->getPath());
Assert::assertStringNotContainsString('<script', $sanitizedFile->getContent());
}
private function getContent(UploadedFile $file): string
{
$content = file_get_contents($file->getPathname());
if (false === $content) {
throw new FileException(sprintf('Could not get the content of the file "%s".', $file->getPathname()));
}
return $content;
}
}

9
tests/Resources/xss.svg Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" style="fill:rgb(210,0,37);" />
<script type="text/javascript">
alert("XSS attack");
</script>
</svg>

After

Width:  |  Height:  |  Size: 364 B