Skip to content

Commit a862ed4

Browse files
committed
feat: Add support for Content-Security-Policy
This adds csp headers and a proxy endpoint to send csp violations to sentry
1 parent 423dd23 commit a862ed4

8 files changed

+246
-2
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Netlogix\Sentry\Controller;
6+
7+
use Neos\Flow\Log\ThrowableStorageInterface;
8+
use Neos\Flow\Mvc\Controller\ActionController;
9+
use Neos\Flow\Annotations as Flow;
10+
use Psr\Http\Client\ClientExceptionInterface;
11+
use Psr\Http\Client\ClientInterface;
12+
use Psr\Http\Message\RequestFactoryInterface;
13+
use Psr\Http\Message\StreamFactoryInterface;
14+
use Sentry\SentrySdk;
15+
16+
class ContentSecurityPolicyController extends ActionController
17+
{
18+
protected $supportedMediaTypes = ['application/csp-report'];
19+
20+
#[Flow\InjectConfiguration(path: 'csp.enable')]
21+
protected bool $enabled = true;
22+
23+
#[Flow\InjectConfiguration(path: 'csp.reports.includedHeaders')]
24+
protected array $includedHeaders = [];
25+
26+
#[Flow\Inject]
27+
protected ThrowableStorageInterface $throwableStorage;
28+
29+
public function indexAction(): string
30+
{
31+
if (!$this->enabled) {
32+
return '';
33+
}
34+
35+
$reportingEndpoint = $this->getSentryReportingEndpoint();
36+
if ($reportingEndpoint === null) {
37+
return '';
38+
}
39+
40+
// TODO: Only report a limited amount to avoid filling up sentry
41+
42+
$body = $this->request->getHttpRequest()->getBody();
43+
$body->rewind();
44+
$postBody = $body->getContents();
45+
46+
$client = $this->objectManager->get(ClientInterface::class);
47+
$requestFactory = $this->objectManager->get(RequestFactoryInterface::class);
48+
$streamFactory = $this->objectManager->get(StreamFactoryInterface::class);
49+
$request = $requestFactory->createRequest('POST', $reportingEndpoint)
50+
->withBody($streamFactory->createStream($postBody));
51+
52+
foreach (array_keys(array_filter($this->includedHeaders)) as $header) {
53+
$headerValue = $this->request->getHttpRequest()->getHeaderLine($header);
54+
if ($headerValue === '') {
55+
continue;
56+
}
57+
58+
$request = $request
59+
->withHeader($header, $headerValue);
60+
}
61+
62+
try {
63+
$client->sendRequest($request);
64+
} catch (ClientExceptionInterface $e) {
65+
$this->throwableStorage->logThrowable($e);
66+
}
67+
68+
return '';
69+
}
70+
71+
protected function getSentryReportingEndpoint(): ?string
72+
{
73+
return SentrySdk::getCurrentHub()->getClient()?->getCspReportUrl();
74+
}
75+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Netlogix\Sentry\Http\Middleware;
6+
7+
use GuzzleHttp\Psr7\Uri;
8+
use Neos\Flow\Annotations as Flow;
9+
use Psr\Http\Message\ResponseInterface;
10+
use Psr\Http\Message\ServerRequestInterface;
11+
use Psr\Http\Message\UriInterface;
12+
use Psr\Http\Server\MiddlewareInterface;
13+
use Psr\Http\Server\RequestHandlerInterface;
14+
15+
class ContentSecurityPolicyMiddleware implements MiddlewareInterface
16+
{
17+
#[Flow\InjectConfiguration(path: 'csp.enable')]
18+
protected bool $enabled = true;
19+
20+
#[Flow\InjectConfiguration(path: 'csp.headers.reportOnly')]
21+
protected bool $reportOnly = true;
22+
23+
#[Flow\InjectConfiguration(path: 'csp.headers.blacklistedPaths')]
24+
protected array $blacklistedPaths = [];
25+
26+
#[Flow\InjectConfiguration(path: 'csp.headers.parts')]
27+
protected array $parts = [];
28+
29+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
30+
{
31+
$response = $handler->handle($request);
32+
33+
if (!$this->enabled) {
34+
return $response;
35+
}
36+
if ($this->parts === [] || $this->isUriInBlocklist($request->getUri())) {
37+
return $response;
38+
}
39+
40+
$response = $this->addReportingEndpoints($request, $response);
41+
$response = $this->addContentSecurityPolicy($request, $response);
42+
43+
return $response;
44+
}
45+
46+
protected function addContentSecurityPolicy(
47+
ServerRequestInterface $request,
48+
ResponseInterface $response
49+
): ResponseInterface {
50+
if ($this->reportOnly) {
51+
$headerName = 'Content-Security-Policy-Report-Only';
52+
} else {
53+
$headerName = 'Content-Security-Policy';
54+
}
55+
56+
$defaultParts = [
57+
'report-uri ' . $this->reportingEndpoint($request),
58+
'report-to csp-endpoint'
59+
];
60+
61+
// TODO: Add support for nonces
62+
$parts = array_merge($this->parts, $defaultParts);
63+
64+
return $response
65+
->withHeader($headerName, trim(join('; ', $parts), "; \n\r\t\v\0"));
66+
}
67+
68+
protected function addReportingEndpoints(
69+
ServerRequestInterface $request,
70+
ResponseInterface $response
71+
): ResponseInterface {
72+
$reportingEndpoints = [
73+
'csp-endpoint' => $this->reportingEndpoint($request),
74+
];
75+
76+
$headerValues = array_reduce(array_keys($reportingEndpoints),
77+
function (array $carry, string $key) use ($reportingEndpoints) {
78+
$carry[$key] = sprintf('%s="%s"', $key, $reportingEndpoints[$key]);
79+
80+
return $carry;
81+
}, []);
82+
83+
return $response
84+
->withHeader('Reporting-Endpoints', join(', ', $headerValues));
85+
}
86+
87+
protected function reportingEndpoint(ServerRequestInterface $request): string
88+
{
89+
$uri = $request->getUri();
90+
91+
return Uri::composeComponents(
92+
$uri->getScheme(),
93+
$uri->getHost(),
94+
'api/csp-report',
95+
'',
96+
''
97+
);
98+
}
99+
100+
public function isUriInBlocklist(UriInterface $uri): bool
101+
{
102+
$path = $uri->getPath();
103+
foreach ($this->blacklistedPaths as $rawPattern => $active) {
104+
if (!$active) {
105+
continue;
106+
}
107+
$pattern = '/' . str_replace('/', '\/', $rawPattern) . '/';
108+
109+
if (preg_match($pattern, $path) === 1) {
110+
return true;
111+
}
112+
}
113+
114+
return false;
115+
}
116+
}

Classes/Package.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ static function (ConfigurationManager $configurationManager) {
2828
ConfigurationManager::CONFIGURATION_TYPE_SETTINGS,
2929
'Netlogix.Sentry.inAppExclude'
3030
);
31-
31+
3232
init([
3333
'dsn' => $dsn,
3434
'integrations' => [

Configuration/Policy.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ privilegeTargets:
55
'Netlogix.Sentry:Backend.EncryptedPayload':
66
matcher: 'method(Netlogix\Sentry\Controller\EncryptedPayloadController->.*())'
77

8+
'Netlogix.Sentry:Public.ContentSecurityPolicy':
9+
matcher: 'method(Netlogix\Sentry\Controller\ContentSecurityPolicyController->.*())'
10+
811
roles:
912

1013
'Neos.Flow:Anonymous':
1114
privileges:
1215
-
1316
privilegeTarget: 'Netlogix.Sentry:Backend.EncryptedPayload'
1417
permission: DENY
18+
-
19+
privilegeTarget: 'Netlogix.Sentry:Public.ContentSecurityPolicy'
20+
permission: GRANT
1521

1622
'Neos.Neos:Administrator':
1723
privileges:

Configuration/Routes.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,15 @@
55
'@controller': 'EncryptedPayload'
66
'@action': 'decrypt'
77
'@format': 'html'
8-
appendExceedingArguments: TRUE
8+
appendExceedingArguments: true
99
httpMethods: ['GET']
10+
11+
- name: 'Content Security Policy'
12+
uriPattern: 'api/csp-report'
13+
defaults:
14+
'@package': 'Netlogix.Sentry'
15+
'@controller': 'ContentSecurityPolicy'
16+
'@action': 'index'
17+
'@format': 'json'
18+
appendExceedingArguments: true
19+
httpMethods: ['POST']

Configuration/Settings.Csp.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
Netlogix:
2+
Sentry:
3+
csp:
4+
# Enable Content-Security-Policy features (csp header & proxying to sentry)
5+
enable: false
6+
7+
headers:
8+
# Whether to use the Content-Security-Policy-Report-Only instead of the Content-Security-Policy header
9+
reportOnly: true
10+
11+
# Regular expressions for paths where no csp headers should be set
12+
blacklistedPaths:
13+
'/neos.*': true
14+
15+
# List of csp header values to include. No need to specify report-uri or report-to as that is handled automatically
16+
parts: []
17+
# parts:
18+
# - "default-src *"
19+
# - "script-src 'self' 'unsafe-eval' 'unsafe-inline'"
20+
# - "style-src 'self' 'unsafe-inline'"
21+
# - "img-src * data:"
22+
23+
reports:
24+
# List of headers to include when proxying the client request to sentry
25+
includedHeaders:
26+
'Referer': true
27+
'User-Agent': true
28+
'Content-Type': true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Neos:
2+
Flow:
3+
http:
4+
middlewares:
5+
'nlxSentryContentSecurityPolicy':
6+
position: 'before dispatch'
7+
middleware: 'Netlogix\Sentry\Http\Middleware\ContentSecurityPolicyMiddleware'

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"php": "^8.0",
88
"neos/flow": "^7.3.6 || ^8.0.4 || ~9.0.0",
99
"sentry/sdk": "^3.1",
10+
"psr/http-client": "^1.0",
11+
"psr/http-factory": "^1.0",
1012
"ext-openssl": "*",
1113
"ext-json": "*"
1214
},

0 commit comments

Comments
 (0)