Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions Classes/Http/CspHeaderMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@

namespace Flowpack\ContentSecurityPolicy\Http;

use Exception;
use Flowpack\ContentSecurityPolicy\Exceptions\DirectivesNormalizerException;
use Flowpack\ContentSecurityPolicy\Exceptions\InvalidDirectiveException;
use Flowpack\ContentSecurityPolicy\Factory\PolicyFactory;
use Flowpack\ContentSecurityPolicy\Helpers\TagHelper;
use Flowpack\ContentSecurityPolicy\Model\Nonce;
use Flowpack\ContentSecurityPolicy\Model\Policy;
use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
use Neos\Flow\Annotations as Flow;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

class CspHeaderMiddleware implements MiddlewareInterface
{
Expand All @@ -43,6 +44,23 @@ class CspHeaderMiddleware implements MiddlewareInterface
*/
protected array $configuration;

/**
* @Flow\InjectConfiguration(path="policies")
* @var array<string, array<string, list<string>>>
*/
protected array $policies;

// TODO: rename to throw-on-configuration-error in next major version
/**
* @Flow\InjectConfiguration(path="throw-invalid-directive-exception")
*/
protected bool $throwInvalidDirectiveException;

/**
* @Flow\Inject
*/
protected LoggerInterface $logger;

/**
* @inheritDoc
* @throws InvalidDirectiveException
Expand Down Expand Up @@ -73,16 +91,30 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
*/
private function getPolicyByCurrentContext(ServerRequestInterface $request): Policy
{
/*
* There is no other way to know if we're in the backend here, we cannot inject
* Neos\Neos\Domain\Service\ContentContext at this point as it throws an error.
*/
if (str_starts_with($request->getUri()->getPath(), '/neos')) {
return $this->policyFactory->create(
$this->nonce,
$this->configuration['backend'],
$this->configuration['custom-backend']
);
$path = $request->getUri()->getPath();

$backendUris = array_merge(
$this->policies['backend']['matchUris'] ?? [],
$this->policies['custom-backend']['matchUris'] ?? []
);

foreach ($backendUris as $pattern) {
$result = preg_match('#' . str_replace('#', '\#', $pattern) . '#', $path);
if ($result === false) {
$message = sprintf('Invalid matchUri pattern "%s": %s', $pattern, preg_last_error_msg());
if ($this->throwInvalidDirectiveException) {
throw new InvalidArgumentException($message);
}
$this->logger->critical($message);
continue;
}
if ($result === 1) {
return $this->policyFactory->create(
$this->nonce,
$this->configuration['backend'],
$this->configuration['custom-backend']
);
}
}

return $this->policyFactory->create(
Expand Down
6 changes: 6 additions & 0 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ Flowpack:
'self': true
'data:': true
custom-backend: [ ]
policies:
backend:
matchUris:
- '^/neos'
custom-backend:
matchUris: []

Neos:
Neos:
Expand Down
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Flowpack.ContentSecurityPolicy

<!-- TOC -->

* [Flowpack.ContentSecurityPolicy](#flowpackcontentsecuritypolicy)
* [Introduction](#introduction)
* [Usage](#usage)
* [Custom directives and values](#custom-directives-and-values)
* [Show CSP configuration](#show-csp-configuration)
* [Disable or report only](#disable-or-report-only)
* [Nonce](#nonce)
* [Backend](#backend)
* [Thank you](#thank-you)

* [Introduction](#introduction)
* [Usage](#usage)
* [Deprecated Configuration](#deprecated-configuration)
* [Custom directives and values](#custom-directives-and-values)
* [Show CSP configuration](#show-csp-configuration)
* [Disable or report only](#disable-or-report-only)
* [Nonce](#nonce)
* [Backend](#backend)
* [Custom backend routes](#custom-backend-routes)
* [Thank you](#thank-you)
<!-- TOC -->

## Introduction
Expand Down Expand Up @@ -190,6 +190,24 @@ Unsafe inline scripts and styles are allowed in the backend because otherwise th

Again you can add your own policies in the custom-backend array the same way as the custom array for the frontend.

### Custom backend routes

By default, the backend policy is applied to all paths starting with `/neos`. If you have additional routes that require
the same permissive policy (e.g. a custom admin UI at `/monocle`), add them to `custom-backend.matchUris`. Each entry
is a PHP regex (without delimiters) matched against the request path.

```yaml
Flowpack:
ContentSecurityPolicy:
policies:
custom-backend:
matchUris:
- '^/monocle(/.*)?$'
```

The built-in `'^/neos'` pattern in `backend.matchUris` is unaffected, so the Neos backend continues to work without any
changes. You only need to touch `backend.matchUris` if you want to replace the default `/neos` match entirely.

## Thank you

This package originates from https://github.com/LarsNieuwenhuizen/Nieuwenhuizen.ContentSecurityPolicy.
Expand Down
126 changes: 125 additions & 1 deletion Tests/Unit/Http/CspHeaderMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Unit\Http;

use Exception;
use Flowpack\ContentSecurityPolicy\Factory\PolicyFactory;
use Flowpack\ContentSecurityPolicy\Helpers\TagHelper;
use Flowpack\ContentSecurityPolicy\Http\CspHeaderMiddleware;
Expand All @@ -17,9 +18,9 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use Throwable;

use function PHPUnit\Framework\once;

#[CoversClass(CspHeaderMiddleware::class)]
Expand All @@ -34,6 +35,7 @@ class CspHeaderMiddlewareTest extends TestCase
private readonly UriInterface&MockObject $uriMock;
private readonly PolicyFactory&MockObject $policyFactoryMock;
private readonly Policy&MockObject $policyMock;
private readonly LoggerInterface&MockObject $loggerMock;

/**
* @throws Throwable
Expand All @@ -51,6 +53,7 @@ protected function setUp(): void
$this->uriMock = $this->createMock(UriInterface::class);
$this->policyFactoryMock = $this->createMock(PolicyFactory::class);
$this->policyMock = $this->createMock(Policy::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);

$this->middlewareReflection = new ReflectionClass($this->middleware);

Expand All @@ -69,6 +72,18 @@ protected function setUp(): void
['backend' => [], 'custom-backend' => [], 'default' => [], 'custom' => [],]
);

$reflectionProperty = $this->middlewareReflection->getProperty('policies');
$reflectionProperty->setValue(
$this->middleware,
['backend' => ['matchUris' => ['^/neos']], 'custom-backend' => ['matchUris' => []]]
);

$reflectionProperty = $this->middlewareReflection->getProperty('throwInvalidDirectiveException');
$reflectionProperty->setValue($this->middleware, true);

$reflectionProperty = $this->middlewareReflection->getProperty('logger');
$reflectionProperty->setValue($this->middleware, $this->loggerMock);

$this->requestHandlerMock->expects($this->once())->method('handle')->willReturn($this->responseMock);
}

Expand Down Expand Up @@ -112,4 +127,113 @@ public function testProcessShouldAddHeadersToResponseAndReplaceBody(): void

$this->middleware->process($this->requestMock, $this->requestHandlerMock);
}

public function testProcessShouldUseBackendPolicyForCustomMatchUri(): void
{
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
$reflectionProperty->setValue(
$this->middleware,
['backend' => ['matchUris' => ['^/neos']], 'custom-backend' => ['matchUris' => ['^/monocle(/.*)?$']]]
);

$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/monocle/dashboard');

$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();

$this->middleware->process($this->requestMock, $this->requestHandlerMock);
}

public function testProcessShouldUseDefaultPolicyWhenNoMatchUriMatches(): void
{
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/monocle/dashboard');

$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();

$this->middleware->process($this->requestMock, $this->requestHandlerMock);
}

public function testProcessShouldNotMatchNeosWhenBackendMatchUrisOverridden(): void
{
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
$reflectionProperty->setValue(
$this->middleware,
['backend' => ['matchUris' => ['^/other']], 'custom-backend' => ['matchUris' => []]]
);

$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');

$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();

$this->middleware->process($this->requestMock, $this->requestHandlerMock);
}

public function testProcessThrowsOnInvalidMatchUriPattern(): void
{
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
$reflectionProperty->setValue(
$this->middleware,
['backend' => ['matchUris' => ['^/neos(']], 'custom-backend' => ['matchUris' => []]]
);

$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');

/*
* preg_match emmits a warning which makes phpunit fail, so we convert warnings to errors and expect an exception
* as we cannot expect warnings
*/
set_error_handler(static function (int $errorCode, string $errorString): never {
throw new Exception($errorString, $errorCode);
}, E_WARNING);
$this->expectExceptionMessage('Compilation failed');

try {
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
} finally {
restore_error_handler();
}
}

public function testProcessLogsInvalidMatchUriPatternInProduction(): void
{
$reflectionProperty = $this->middlewareReflection->getProperty('throwInvalidDirectiveException');
$reflectionProperty->setValue($this->middleware, false);

$reflectionProperty = $this->middlewareReflection->getProperty('policies');
$reflectionProperty->setValue(
$this->middleware,
['backend' => ['matchUris' => ['^/neos(']], 'custom-backend' => ['matchUris' => []]]
);

$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');

$this->loggerMock->expects($this->once())->method('critical');
$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();

/*
* preg_match emmits a warning which makes phpunit fail, so we suppress the warning that would make phpunit
* fail
*/
set_error_handler(static function (): bool {
return true;
}, E_WARNING);

try {
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
} finally {
restore_error_handler();
}
}
}
Loading