Skip to content

Commit b15aff6

Browse files
authored
feature: allow custom backend uris (#15)
Closes #14
1 parent 70424f9 commit b15aff6

4 files changed

Lines changed: 202 additions & 22 deletions

File tree

Classes/Http/CspHeaderMiddleware.php

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44

55
namespace Flowpack\ContentSecurityPolicy\Http;
66

7-
use Exception;
87
use Flowpack\ContentSecurityPolicy\Exceptions\DirectivesNormalizerException;
98
use Flowpack\ContentSecurityPolicy\Exceptions\InvalidDirectiveException;
109
use Flowpack\ContentSecurityPolicy\Factory\PolicyFactory;
1110
use Flowpack\ContentSecurityPolicy\Helpers\TagHelper;
1211
use Flowpack\ContentSecurityPolicy\Model\Nonce;
1312
use Flowpack\ContentSecurityPolicy\Model\Policy;
1413
use GuzzleHttp\Psr7\Utils;
14+
use InvalidArgumentException;
1515
use Neos\Flow\Annotations as Flow;
1616
use Psr\Http\Message\ResponseInterface;
1717
use Psr\Http\Message\ServerRequestInterface;
1818
use Psr\Http\Server\MiddlewareInterface;
1919
use Psr\Http\Server\RequestHandlerInterface;
20+
use Psr\Log\LoggerInterface;
2021

2122
class CspHeaderMiddleware implements MiddlewareInterface
2223
{
@@ -43,6 +44,23 @@ class CspHeaderMiddleware implements MiddlewareInterface
4344
*/
4445
protected array $configuration;
4546

47+
/**
48+
* @Flow\InjectConfiguration(path="policies")
49+
* @var array<string, array<string, list<string>>>
50+
*/
51+
protected array $policies;
52+
53+
// TODO: rename to throw-on-configuration-error in next major version
54+
/**
55+
* @Flow\InjectConfiguration(path="throw-invalid-directive-exception")
56+
*/
57+
protected bool $throwInvalidDirectiveException;
58+
59+
/**
60+
* @Flow\Inject
61+
*/
62+
protected LoggerInterface $logger;
63+
4664
/**
4765
* @inheritDoc
4866
* @throws InvalidDirectiveException
@@ -73,16 +91,30 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
7391
*/
7492
private function getPolicyByCurrentContext(ServerRequestInterface $request): Policy
7593
{
76-
/*
77-
* There is no other way to know if we're in the backend here, we cannot inject
78-
* Neos\Neos\Domain\Service\ContentContext at this point as it throws an error.
79-
*/
80-
if (str_starts_with($request->getUri()->getPath(), '/neos')) {
81-
return $this->policyFactory->create(
82-
$this->nonce,
83-
$this->configuration['backend'],
84-
$this->configuration['custom-backend']
85-
);
94+
$path = $request->getUri()->getPath();
95+
96+
$backendUris = array_merge(
97+
$this->policies['backend']['matchUris'] ?? [],
98+
$this->policies['custom-backend']['matchUris'] ?? []
99+
);
100+
101+
foreach ($backendUris as $pattern) {
102+
$result = preg_match('#' . str_replace('#', '\#', $pattern) . '#', $path);
103+
if ($result === false) {
104+
$message = sprintf('Invalid matchUri pattern "%s": %s', $pattern, preg_last_error_msg());
105+
if ($this->throwInvalidDirectiveException) {
106+
throw new InvalidArgumentException($message);
107+
}
108+
$this->logger->critical($message);
109+
continue;
110+
}
111+
if ($result === 1) {
112+
return $this->policyFactory->create(
113+
$this->nonce,
114+
$this->configuration['backend'],
115+
$this->configuration['custom-backend']
116+
);
117+
}
86118
}
87119

88120
return $this->policyFactory->create(

Configuration/Settings.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ Flowpack:
6767
'self': true
6868
'data:': true
6969
custom-backend: [ ]
70+
policies:
71+
backend:
72+
matchUris:
73+
- '^/neos'
74+
custom-backend:
75+
matchUris: []
7076

7177
Neos:
7278
Neos:

README.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# Flowpack.ContentSecurityPolicy
22

33
<!-- TOC -->
4-
54
* [Flowpack.ContentSecurityPolicy](#flowpackcontentsecuritypolicy)
6-
* [Introduction](#introduction)
7-
* [Usage](#usage)
8-
* [Custom directives and values](#custom-directives-and-values)
9-
* [Show CSP configuration](#show-csp-configuration)
10-
* [Disable or report only](#disable-or-report-only)
11-
* [Nonce](#nonce)
12-
* [Backend](#backend)
13-
* [Thank you](#thank-you)
14-
5+
* [Introduction](#introduction)
6+
* [Usage](#usage)
7+
* [Deprecated Configuration](#deprecated-configuration)
8+
* [Custom directives and values](#custom-directives-and-values)
9+
* [Show CSP configuration](#show-csp-configuration)
10+
* [Disable or report only](#disable-or-report-only)
11+
* [Nonce](#nonce)
12+
* [Backend](#backend)
13+
* [Custom backend routes](#custom-backend-routes)
14+
* [Thank you](#thank-you)
1515
<!-- TOC -->
1616

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

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

193+
### Custom backend routes
194+
195+
By default, the backend policy is applied to all paths starting with `/neos`. If you have additional routes that require
196+
the same permissive policy (e.g. a custom admin UI at `/monocle`), add them to `custom-backend.matchUris`. Each entry
197+
is a PHP regex (without delimiters) matched against the request path.
198+
199+
```yaml
200+
Flowpack:
201+
ContentSecurityPolicy:
202+
policies:
203+
custom-backend:
204+
matchUris:
205+
- '^/monocle(/.*)?$'
206+
```
207+
208+
The built-in `'^/neos'` pattern in `backend.matchUris` is unaffected, so the Neos backend continues to work without any
209+
changes. You only need to touch `backend.matchUris` if you want to replace the default `/neos` match entirely.
210+
193211
## Thank you
194212

195213
This package originates from https://github.com/LarsNieuwenhuizen/Nieuwenhuizen.ContentSecurityPolicy.

Tests/Unit/Http/CspHeaderMiddlewareTest.php

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Unit\Http;
66

7+
use Exception;
78
use Flowpack\ContentSecurityPolicy\Factory\PolicyFactory;
89
use Flowpack\ContentSecurityPolicy\Helpers\TagHelper;
910
use Flowpack\ContentSecurityPolicy\Http\CspHeaderMiddleware;
@@ -17,9 +18,9 @@
1718
use Psr\Http\Message\ServerRequestInterface;
1819
use Psr\Http\Message\UriInterface;
1920
use Psr\Http\Server\RequestHandlerInterface;
21+
use Psr\Log\LoggerInterface;
2022
use ReflectionClass;
2123
use Throwable;
22-
2324
use function PHPUnit\Framework\once;
2425

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

3840
/**
3941
* @throws Throwable
@@ -51,6 +53,7 @@ protected function setUp(): void
5153
$this->uriMock = $this->createMock(UriInterface::class);
5254
$this->policyFactoryMock = $this->createMock(PolicyFactory::class);
5355
$this->policyMock = $this->createMock(Policy::class);
56+
$this->loggerMock = $this->createMock(LoggerInterface::class);
5457

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

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

75+
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
76+
$reflectionProperty->setValue(
77+
$this->middleware,
78+
['backend' => ['matchUris' => ['^/neos']], 'custom-backend' => ['matchUris' => []]]
79+
);
80+
81+
$reflectionProperty = $this->middlewareReflection->getProperty('throwInvalidDirectiveException');
82+
$reflectionProperty->setValue($this->middleware, true);
83+
84+
$reflectionProperty = $this->middlewareReflection->getProperty('logger');
85+
$reflectionProperty->setValue($this->middleware, $this->loggerMock);
86+
7287
$this->requestHandlerMock->expects($this->once())->method('handle')->willReturn($this->responseMock);
7388
}
7489

@@ -112,4 +127,113 @@ public function testProcessShouldAddHeadersToResponseAndReplaceBody(): void
112127

113128
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
114129
}
130+
131+
public function testProcessShouldUseBackendPolicyForCustomMatchUri(): void
132+
{
133+
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
134+
$reflectionProperty->setValue(
135+
$this->middleware,
136+
['backend' => ['matchUris' => ['^/neos']], 'custom-backend' => ['matchUris' => ['^/monocle(/.*)?$']]]
137+
);
138+
139+
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
140+
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/monocle/dashboard');
141+
142+
$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
143+
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
144+
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();
145+
146+
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
147+
}
148+
149+
public function testProcessShouldUseDefaultPolicyWhenNoMatchUriMatches(): void
150+
{
151+
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
152+
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/monocle/dashboard');
153+
154+
$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
155+
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
156+
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();
157+
158+
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
159+
}
160+
161+
public function testProcessShouldNotMatchNeosWhenBackendMatchUrisOverridden(): void
162+
{
163+
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
164+
$reflectionProperty->setValue(
165+
$this->middleware,
166+
['backend' => ['matchUris' => ['^/other']], 'custom-backend' => ['matchUris' => []]]
167+
);
168+
169+
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
170+
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');
171+
172+
$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
173+
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
174+
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();
175+
176+
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
177+
}
178+
179+
public function testProcessThrowsOnInvalidMatchUriPattern(): void
180+
{
181+
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
182+
$reflectionProperty->setValue(
183+
$this->middleware,
184+
['backend' => ['matchUris' => ['^/neos(']], 'custom-backend' => ['matchUris' => []]]
185+
);
186+
187+
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
188+
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');
189+
190+
/*
191+
* preg_match emmits a warning which makes phpunit fail, so we convert warnings to errors and expect an exception
192+
* as we cannot expect warnings
193+
*/
194+
set_error_handler(static function (int $errorCode, string $errorString): never {
195+
throw new Exception($errorString, $errorCode);
196+
}, E_WARNING);
197+
$this->expectExceptionMessage('Compilation failed');
198+
199+
try {
200+
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
201+
} finally {
202+
restore_error_handler();
203+
}
204+
}
205+
206+
public function testProcessLogsInvalidMatchUriPatternInProduction(): void
207+
{
208+
$reflectionProperty = $this->middlewareReflection->getProperty('throwInvalidDirectiveException');
209+
$reflectionProperty->setValue($this->middleware, false);
210+
211+
$reflectionProperty = $this->middlewareReflection->getProperty('policies');
212+
$reflectionProperty->setValue(
213+
$this->middleware,
214+
['backend' => ['matchUris' => ['^/neos(']], 'custom-backend' => ['matchUris' => []]]
215+
);
216+
217+
$this->requestMock->expects($this->once())->method('getUri')->willReturn($this->uriMock);
218+
$this->uriMock->expects($this->once())->method('getPath')->willReturn('/neos');
219+
220+
$this->loggerMock->expects($this->once())->method('critical');
221+
$this->policyFactoryMock->expects($this->once())->method('create')->willReturn($this->policyMock);
222+
$this->policyMock->expects($this->once())->method('hasNonceDirectiveValue')->willReturn(false);
223+
$this->responseMock->expects($this->once())->method('withAddedHeader')->willReturnSelf();
224+
225+
/*
226+
* preg_match emmits a warning which makes phpunit fail, so we suppress the warning that would make phpunit
227+
* fail
228+
*/
229+
set_error_handler(static function (): bool {
230+
return true;
231+
}, E_WARNING);
232+
233+
try {
234+
$this->middleware->process($this->requestMock, $this->requestHandlerMock);
235+
} finally {
236+
restore_error_handler();
237+
}
238+
}
115239
}

0 commit comments

Comments
 (0)