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 diff --git a/Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php b/Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php new file mode 100644 index 0000000..0a9a934 --- /dev/null +++ b/Classes/ContentSecurityPolicy/ContentSecurityPolicyMiddleware.php @@ -0,0 +1,133 @@ +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; + } +} 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 @@ +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/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.Eel.yaml b/Configuration/Settings.Eel.yaml new file mode 100644 index 0000000..eb5055a --- /dev/null +++ b/Configuration/Settings.Eel.yaml @@ -0,0 +1,5 @@ +Neos: + # Fusion might not be installed, but adding the Registry to the defaultContext won't hurt + Fusion: + defaultContext: + 'Netlogix.CspRegistry': 'Netlogix\Sentry\ContentSecurityPolicy\Registry' diff --git a/Configuration/Settings.Middleware.yaml b/Configuration/Settings.Middleware.yaml new file mode 100644 index 0000000..22a7600 --- /dev/null +++ b/Configuration/Settings.Middleware.yaml @@ -0,0 +1,7 @@ +Neos: + Flow: + http: + middlewares: + 'nlxSentryContentSecurityPolicy': + position: 'before dispatch' + middleware: 'Netlogix\Sentry\ContentSecurityPolicy\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": "*" },