Skip to content
Draft
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
31 changes: 17 additions & 14 deletions .github/workflows/functionaltests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,35 @@ jobs:
extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite
ini-values: opcache.fast_shutdown=0

- name: "[1/5] Create composer project - Cache composer dependencies"
uses: actions/cache@v1
with:
path: ~/.composer/cache
key: php-${{ matrix.php-version }}-flow-${{ matrix.flow-version }}-composer-${{ hashFiles('composer.json') }}
restore-keys: |
php-${{ matrix.php-version }}-flow-${{ matrix.flow-version }}-composer-
php-${{ matrix.php-version }}-flow-

- name: "[2/5] Create composer project - No install"
- name: "Create composer project - No install"
run: composer create-project neos/flow-base-distribution ${{ env.FLOW_DIST_FOLDER }} --prefer-dist --no-progress --no-install "^${{ matrix.flow-version }}"

- name: "[3/5] Allow neos composer plugin"
- name: "Allow neos composer plugin"
run: composer config --no-plugins allow-plugins.neos/composer-plugin true
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: "[4/5] Create composer project - Require behat in compatible version"
- name: "Create composer project - Require behat in compatible version"
run: composer require --dev --no-update "neos/behat:@dev"
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: "[5/5] Create composer project - Install project"
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: Cache dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-composer-

- name: "Create composer project - Install project"
run: composer install
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: ${{ env.FLOW_DIST_FOLDER }}/DistributionPackages/Netlogix.Sentry

Expand Down
31 changes: 17 additions & 14 deletions .github/workflows/unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,35 @@ jobs:
extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite
ini-values: opcache.fast_shutdown=0

- name: "[1/5] Create composer project - Cache composer dependencies"
uses: actions/cache@v1
with:
path: ~/.composer/cache
key: php-${{ matrix.php-version }}-flow-${{ matrix.flow-version }}-composer-${{ hashFiles('composer.json') }}
restore-keys: |
php-${{ matrix.php-version }}-flow-${{ matrix.flow-version }}-composer-
php-${{ matrix.php-version }}-flow-

- name: "[2/5] Create composer project - No install"
- name: "Create composer project - No install"
run: composer create-project neos/flow-base-distribution ${{ env.FLOW_DIST_FOLDER }} --prefer-dist --no-progress --no-install "^${{ matrix.flow-version }}"

- name: "[3/5] Allow neos composer plugin"
- name: "Allow neos composer plugin"
run: composer config --no-plugins allow-plugins.neos/composer-plugin true
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: "[4/5] Create composer project - Require behat in compatible version"
- name: "Create composer project - Require behat in compatible version"
run: composer require --dev --no-update "neos/behat:@dev"
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: "[5/5] Create composer project - Install project"
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: Cache dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-composer-

- name: "Create composer project - Install project"
run: composer install
working-directory: ${{ env.FLOW_DIST_FOLDER }}

- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: ${{ env.FLOW_DIST_FOLDER }}/DistributionPackages/Netlogix.Sentry

Expand Down
133 changes: 133 additions & 0 deletions Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace Netlogix\Sentry\ContentSecurityPolicy;

use GuzzleHttp\Psr7\Uri;
use Neos\Flow\Annotations as Flow;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ContentSecurityPolicyMiddleware implements MiddlewareInterface
{
#[Flow\InjectConfiguration(path: 'csp.enable')]
protected bool $enabled = true;

#[Flow\InjectConfiguration(path: 'csp.headers.reportOnly')]
protected bool $reportOnly = true;

#[Flow\InjectConfiguration(path: 'csp.headers.blacklistedPaths')]
protected array $blacklistedPaths = [];

#[Flow\InjectConfiguration(path: 'csp.headers.parts')]
protected array $parts = [];

#[Flow\Inject]
protected Registry $registry;

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);

if (!$this->enabled) {
return $response;
}
if ($this->parts === [] || $this->isUriInBlocklist($request->getUri())) {
return $response;
}

$response = $this->addReportingEndpoints($request, $response);
$response = $this->addContentSecurityPolicy($request, $response);

return $response;
}

protected function addContentSecurityPolicy(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
if ($this->reportOnly) {
$headerName = 'Content-Security-Policy-Report-Only';
} else {
$headerName = 'Content-Security-Policy';
}

$parts = $this->parts;
if ($this->registry->getSafeInlineScriptHashes() !== []) {
$safeInlineScripts = join(
' ',
array_map(fn (string $hash) => sprintf("'%s'", $hash), $this->registry->getSafeInlineScriptHashes())
);
$existingScriptSrc = array_find_key($parts, fn (string $part) => str_starts_with($part, 'script-src'));

if ($existingScriptSrc !== null) {
$parts[$existingScriptSrc] = $parts[$existingScriptSrc] . ' ' . $safeInlineScripts;
} else {
$parts[] = 'script-src ' . $safeInlineScripts;
}
}

$defaultParts = [
'report-uri ' . $this->reportingEndpoint($request),
'report-to csp-endpoint'
];

$parts = array_merge($parts, $defaultParts);

return $response
->withHeader($headerName, trim(join('; ', $parts), "; \n\r\t\v\0"));
}

protected function addReportingEndpoints(
ServerRequestInterface $request,
ResponseInterface $response
): ResponseInterface {
$reportingEndpoints = [
'csp-endpoint' => $this->reportingEndpoint($request),
];

$headerValues = array_reduce(array_keys($reportingEndpoints),
function (array $carry, string $key) use ($reportingEndpoints) {
$carry[$key] = sprintf('%s="%s"', $key, $reportingEndpoints[$key]);

return $carry;
}, []);

return $response
->withHeader('Reporting-Endpoints', join(', ', $headerValues));
}

protected function reportingEndpoint(ServerRequestInterface $request): string
{
$uri = $request->getUri();

return Uri::composeComponents(
$uri->getScheme(),
$uri->getHost(),
'api/csp-report',
'',
''
);
}

public function isUriInBlocklist(UriInterface $uri): bool
{
$path = $uri->getPath();
foreach ($this->blacklistedPaths as $rawPattern => $active) {
if (!$active) {
continue;
}
$pattern = '/' . str_replace('/', '\/', $rawPattern) . '/';

if (preg_match($pattern, $path) === 1) {
return true;
}
}

return false;
}
}
42 changes: 42 additions & 0 deletions Classes/ContentSecurityPolicy/Registry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Netlogix\Sentry\ContentSecurityPolicy;

use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Annotations as Flow;

/**
* @Flow\Scope("singleton")
*/
#[Flow\Scope('singleton')]
class Registry implements ProtectedContextAwareInterface
{
protected static array $safeInlineScriptHashes = [];

/**
* Registers a safe inline script from the provided source.
*
* @param string $script The script content that needs to be registered as a safe inline script.
* @param string $algorithm The algorithm to use for hashing the script content (default: sha256)
* @return string The trimmed script content is returned
*/
public function registerSafeInlineScriptFromSource(string $script, string $algorithm = 'sha256'): string
{
$hash = base64_encode(hash($algorithm, $script, true));
self::$safeInlineScriptHashes[] = sprintf('%s-%s', $algorithm, $hash);

return $script;
}

public function getSafeInlineScriptHashes(): array
{
return self::$safeInlineScriptHashes;
}

public function allowsCallOfMethod($methodName): bool
{
return true;
}
}
75 changes: 75 additions & 0 deletions Classes/Controller/ContentSecurityPolicyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Netlogix\Sentry\Controller;

use Neos\Flow\Log\ThrowableStorageInterface;
use Neos\Flow\Mvc\Controller\ActionController;
use Neos\Flow\Annotations as Flow;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Sentry\SentrySdk;

class ContentSecurityPolicyController extends ActionController
{
protected $supportedMediaTypes = ['application/csp-report'];

#[Flow\InjectConfiguration(path: 'csp.enable')]
protected bool $enabled = true;

#[Flow\InjectConfiguration(path: 'csp.reports.includedHeaders')]
protected array $includedHeaders = [];

#[Flow\Inject]
protected ThrowableStorageInterface $throwableStorage;

public function indexAction(): string
{
if (!$this->enabled) {
return '';
}

$reportingEndpoint = $this->getSentryReportingEndpoint();
if ($reportingEndpoint === null) {
return '';
}

// TODO: Only report a limited amount to avoid filling up sentry

$body = $this->request->getHttpRequest()->getBody();
$body->rewind();
$postBody = $body->getContents();

$client = $this->objectManager->get(ClientInterface::class);
$requestFactory = $this->objectManager->get(RequestFactoryInterface::class);
$streamFactory = $this->objectManager->get(StreamFactoryInterface::class);
$request = $requestFactory->createRequest('POST', $reportingEndpoint)
->withBody($streamFactory->createStream($postBody));

foreach (array_keys(array_filter($this->includedHeaders)) as $header) {
$headerValue = $this->request->getHttpRequest()->getHeaderLine($header);
if ($headerValue === '') {
continue;
}

$request = $request
->withHeader($header, $headerValue);
}

try {
$client->sendRequest($request);
} catch (ClientExceptionInterface $e) {
$this->throwableStorage->logThrowable($e);
}

return '';
}

protected function getSentryReportingEndpoint(): ?string
{
return SentrySdk::getCurrentHub()->getClient()?->getCspReportUrl();
}
}
2 changes: 1 addition & 1 deletion Classes/Package.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ static function (ConfigurationManager $configurationManager) {
ConfigurationManager::CONFIGURATION_TYPE_SETTINGS,
'Netlogix.Sentry.inAppExclude'
);

init([
'dsn' => $dsn,
'integrations' => [
Expand Down
6 changes: 6 additions & 0 deletions Configuration/Policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ privilegeTargets:
'Netlogix.Sentry:Backend.EncryptedPayload':
matcher: 'method(Netlogix\Sentry\Controller\EncryptedPayloadController->.*())'

'Netlogix.Sentry:Public.ContentSecurityPolicy':
matcher: 'method(Netlogix\Sentry\Controller\ContentSecurityPolicyController->.*())'

roles:

'Neos.Flow:Anonymous':
privileges:
-
privilegeTarget: 'Netlogix.Sentry:Backend.EncryptedPayload'
permission: DENY
-
privilegeTarget: 'Netlogix.Sentry:Public.ContentSecurityPolicy'
permission: GRANT

'Neos.Neos:Administrator':
privileges:
Expand Down
12 changes: 11 additions & 1 deletion Configuration/Routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,15 @@
'@controller': 'EncryptedPayload'
'@action': 'decrypt'
'@format': 'html'
appendExceedingArguments: TRUE
appendExceedingArguments: true
httpMethods: ['GET']

- name: 'Content Security Policy'
uriPattern: 'api/csp-report'
defaults:
'@package': 'Netlogix.Sentry'
'@controller': 'ContentSecurityPolicy'
'@action': 'index'
'@format': 'json'
appendExceedingArguments: true
httpMethods: ['POST']
Loading