From a862ed44de4cfa3869cdae61784266faeed7cb65 Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Tue, 22 Apr 2025 18:05:15 +0200 Subject: [PATCH 1/3] feat: Add support for Content-Security-Policy This adds csp headers and a proxy endpoint to send csp violations to sentry --- .../ContentSecurityPolicyController.php | 75 +++++++++++ .../ContentSecurityPolicyMiddleware.php | 116 ++++++++++++++++++ Classes/Package.php | 2 +- Configuration/Policy.yaml | 6 + Configuration/Routes.yaml | 12 +- Configuration/Settings.Csp.yaml | 28 +++++ Configuration/Settings.Middleware.yaml | 7 ++ composer.json | 2 + 8 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 Classes/Controller/ContentSecurityPolicyController.php create mode 100644 Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php create mode 100644 Configuration/Settings.Csp.yaml create mode 100644 Configuration/Settings.Middleware.yaml diff --git a/Classes/Controller/ContentSecurityPolicyController.php b/Classes/Controller/ContentSecurityPolicyController.php new file mode 100644 index 0000000..9b86e72 --- /dev/null +++ b/Classes/Controller/ContentSecurityPolicyController.php @@ -0,0 +1,75 @@ +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(); + } +} diff --git a/Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php b/Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php new file mode 100644 index 0000000..f8f05ff --- /dev/null +++ b/Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php @@ -0,0 +1,116 @@ +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'; + } + + $defaultParts = [ + 'report-uri ' . $this->reportingEndpoint($request), + 'report-to csp-endpoint' + ]; + + // TODO: Add support for nonces + $parts = array_merge($this->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; + } +} diff --git a/Classes/Package.php b/Classes/Package.php index 23f21c3..2c82a38 100644 --- a/Classes/Package.php +++ b/Classes/Package.php @@ -28,7 +28,7 @@ static function (ConfigurationManager $configurationManager) { ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, 'Netlogix.Sentry.inAppExclude' ); - + init([ 'dsn' => $dsn, 'integrations' => [ diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml index 713f85f..520b36a 100644 --- a/Configuration/Policy.yaml +++ b/Configuration/Policy.yaml @@ -5,6 +5,9 @@ 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': @@ -12,6 +15,9 @@ roles: - privilegeTarget: 'Netlogix.Sentry:Backend.EncryptedPayload' permission: DENY + - + privilegeTarget: 'Netlogix.Sentry:Public.ContentSecurityPolicy' + permission: GRANT 'Neos.Neos:Administrator': privileges: diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index ecd4b6a..d208218 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -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'] diff --git a/Configuration/Settings.Csp.yaml b/Configuration/Settings.Csp.yaml new file mode 100644 index 0000000..c6cb4f1 --- /dev/null +++ b/Configuration/Settings.Csp.yaml @@ -0,0 +1,28 @@ +Netlogix: + Sentry: + csp: + # Enable Content-Security-Policy features (csp header & proxying to sentry) + enable: false + + headers: + # Whether to use the Content-Security-Policy-Report-Only instead of the Content-Security-Policy header + reportOnly: true + + # Regular expressions for paths where no csp headers should be set + blacklistedPaths: + '/neos.*': true + + # List of csp header values to include. No need to specify report-uri or report-to as that is handled automatically + parts: [] + # parts: + # - "default-src *" + # - "script-src 'self' 'unsafe-eval' 'unsafe-inline'" + # - "style-src 'self' 'unsafe-inline'" + # - "img-src * data:" + + reports: + # List of headers to include when proxying the client request to sentry + includedHeaders: + 'Referer': true + 'User-Agent': true + 'Content-Type': true diff --git a/Configuration/Settings.Middleware.yaml b/Configuration/Settings.Middleware.yaml new file mode 100644 index 0000000..e17fb3f --- /dev/null +++ b/Configuration/Settings.Middleware.yaml @@ -0,0 +1,7 @@ +Neos: + Flow: + http: + middlewares: + 'nlxSentryContentSecurityPolicy': + position: 'before dispatch' + middleware: 'Netlogix\Sentry\Http\Middleware\ContentSecurityPolicyMiddleware' diff --git a/composer.json b/composer.json index 9524425..426397f 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,8 @@ "php": "^8.0", "neos/flow": "^7.3.6 || ^8.0.4 || ~9.0.0", "sentry/sdk": "^3.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", "ext-openssl": "*", "ext-json": "*" }, From 9521798c1e33ec396456f567fffc80d24c0718a2 Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Fri, 25 Apr 2025 11:52:52 +0200 Subject: [PATCH 2/3] feat: Add CSP registry for unsafe inline scripts This provides a registry for registering unsafe inline scripts. Their hashes will be added to the "script-src" part of the Content-Security-Policy header. --- .../ContentSecurityPolicyMiddleware.php | 23 ++++++++-- Classes/ContentSecurityPolicy/Registry.php | 42 +++++++++++++++++++ Configuration/Settings.Eel.yaml | 5 +++ Configuration/Settings.Middleware.yaml | 2 +- 4 files changed, 68 insertions(+), 4 deletions(-) rename Classes/{Http/Middleware => ContentSecurityPolicy}/ContentSecurityPolicyMiddleware.php (80%) create mode 100644 Classes/ContentSecurityPolicy/Registry.php create mode 100644 Configuration/Settings.Eel.yaml diff --git a/Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php b/Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php similarity index 80% rename from Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php rename to Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php index f8f05ff..0a9a934 100644 --- a/Classes/Http/Middleware/ContentSecurityPolicyMiddleware.php +++ b/Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Netlogix\Sentry\Http\Middleware; +namespace Netlogix\Sentry\ContentSecurityPolicy; use GuzzleHttp\Psr7\Uri; use Neos\Flow\Annotations as Flow; @@ -26,6 +26,9 @@ class ContentSecurityPolicyMiddleware implements MiddlewareInterface #[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); @@ -53,13 +56,27 @@ protected function addContentSecurityPolicy( $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' ]; - // TODO: Add support for nonces - $parts = array_merge($this->parts, $defaultParts); + $parts = array_merge($parts, $defaultParts); return $response ->withHeader($headerName, trim(join('; ', $parts), "; \n\r\t\v\0")); diff --git a/Classes/ContentSecurityPolicy/Registry.php b/Classes/ContentSecurityPolicy/Registry.php new file mode 100644 index 0000000..292530c --- /dev/null +++ b/Classes/ContentSecurityPolicy/Registry.php @@ -0,0 +1,42 @@ + Date: Fri, 25 Apr 2025 11:58:21 +0200 Subject: [PATCH 3/3] fix: Replace deprecated github actions --- .github/workflows/functionaltests.yml | 31 +++++++++++++++------------ .github/workflows/unittests.yml | 31 +++++++++++++++------------ 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/.github/workflows/functionaltests.yml b/.github/workflows/functionaltests.yml index abb7534..63ec05b 100644 --- a/.github/workflows/functionaltests.yml +++ b/.github/workflows/functionaltests.yml @@ -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 diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 8fd5c0e..750b4b1 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -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