From 324398a483abd462f47cb77ac52f424ce874428d Mon Sep 17 00:00:00 2001 From: aubes <3941035+aubes@users.noreply.github.com> Date: Wed, 6 May 2026 23:33:16 +0200 Subject: [PATCH 1/2] Preset hardening --- CHANGELOG.md | 14 +++++++++ README.md | 56 ++++++++++++++++++++++++++++----- src/Command/CSPCheckCommand.php | 4 +++ src/Preset/CSPPreset.php | 7 +++-- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7440c17..524df46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [2.1.0] + +### Changed + +- **`strict` preset**: added `'unsafe-inline'` and `https:` as CSP Level 1/2 fallbacks alongside `'strict-dynamic'` (modern browsers ignore them when `'strict-dynamic'` is set), and added `form-action 'self'`. +- **`permissive` preset**: added `'unsafe-eval'` to `script-src` (most legacy apps that need `unsafe-inline` also need `eval`), `connect-src 'self' https:` for XHR/fetch/WebSocket calls, and `form-action 'self'`. + +### Fixed + +- `csp:check` no longer reports `'unsafe-inline'` as an error when `'strict-dynamic'` is present in the same directive (CSP Level 3 browsers ignore `'unsafe-inline'` in that case, so it's a CSP1/2 fallback, not a vulnerability). + ## [2.0.0] ### Breaking changes @@ -49,4 +62,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `symfony/twig-bundle` is now optional: install it explicitly if you use nonce/hash Twig helpers +[2.1.0]: https://github.com/aubes/csp-bundle/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/aubes/csp-bundle/compare/v1.0.0...v2.0.0 diff --git a/README.md b/README.md index 5b0b4f0..a3d4c7a 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,24 @@ csp: ### Presets -Three built-in presets provide sensible defaults: +Three built-in presets provide sensible defaults. Preset policies are merged with your custom policies: your policies extend the preset, they don't replace it. -| Preset | Description | -|---|---| -| `strict` | Nonce-based with `strict-dynamic`, `object-src 'none'`, `base-uri 'none'` | -| `permissive` | `'self'` + `'unsafe-inline'`, suitable for legacy apps that cannot use nonces | -| `api` | `default-src 'none'`, no framing, no forms | +#### `strict` -Preset policies are merged with your custom policies. Your policies extend the preset, they don't replace it. +Nonce-based policy with `strict-dynamic`. Recommended starting point when your templates can inject nonces on inline scripts and styles. -> **Note:** The `strict` preset uses `strict-dynamic` which requires nonces to work. Without nonces, all scripts will be blocked. Make sure you use the Twig nonce helpers in your templates: +```text +default-src 'self' +script-src 'strict-dynamic' 'unsafe-inline' https: +style-src 'self' +object-src 'none' +base-uri 'none' +form-action 'self' +frame-ancestors 'self' +upgrade-insecure-requests +``` + +> **Important:** `strict-dynamic` requires nonces to work. Modern browsers (CSP Level 3) ignore the `'unsafe-inline'` / `https:` fallbacks once `'strict-dynamic'` is enforced, so without nonces all your inline scripts will be blocked there. Use the Twig nonce helpers in your templates: > > ```twig > {# Block tag (recommended) #} @@ -94,6 +101,39 @@ Preset policies are merged with your custom policies. Your policies extend the p > > ``` +#### `permissive` + +Allows `unsafe-inline` and `unsafe-eval`. Designed for legacy apps that cannot adopt nonces yet, but still want defense in depth. + +```text +default-src 'self' +script-src 'self' 'unsafe-inline' 'unsafe-eval' +style-src 'self' 'unsafe-inline' +img-src 'self' data: +font-src 'self' +connect-src 'self' https: +object-src 'none' +base-uri 'self' +form-action 'self' +frame-ancestors 'self' +upgrade-insecure-requests +``` + +This preset is **less secure than `strict`** (no XSS protection from inline scripts), but it's a reasonable baseline while you incrementally add nonces and migrate to `strict`. Pair it with a [gradual rollout](#gradual-rollout). + +#### `api` + +Locks everything down. Designed for JSON APIs and other endpoints that don't render HTML. + +```text +default-src 'none' +frame-ancestors 'none' +base-uri 'none' +form-action 'none' +``` + +Apply this preset to your API controllers via `#[CSPGroup('api')]` or a route default. + ### Directive names Use underscore-separated names in YAML configuration: diff --git a/src/Command/CSPCheckCommand.php b/src/Command/CSPCheckCommand.php index 2c5c893..d437d1c 100644 --- a/src/Command/CSPCheckCommand.php +++ b/src/Command/CSPCheckCommand.php @@ -162,6 +162,10 @@ private function checkUnsafeInline(string $group, array $directives): void { foreach (['script-src', 'script-src-elem', 'script-src-attr'] as $directive) { if (isset($directives[$directive]) && \in_array("'unsafe-inline'", $directives[$directive], true)) { + if (\in_array("'strict-dynamic'", $directives[$directive], true)) { + continue; + } + if (!$this->hasNonceOrHash($directives[$directive])) { $this->finding('error', $group, $directive, "'unsafe-inline' allows execution of arbitrary inline scripts."); } diff --git a/src/Preset/CSPPreset.php b/src/Preset/CSPPreset.php index a006a29..8d43013 100644 --- a/src/Preset/CSPPreset.php +++ b/src/Preset/CSPPreset.php @@ -18,21 +18,24 @@ public function policies(): array return match ($this) { self::Strict => [ 'default-src' => ['self'], - 'script-src' => ['strict-dynamic'], + 'script-src' => ['strict-dynamic', 'unsafe-inline', 'https:'], 'style-src' => ['self'], 'object-src' => ['none'], 'base-uri' => ['none'], + 'form-action' => ['self'], 'frame-ancestors' => ['self'], 'upgrade-insecure-requests' => [], ], self::Permissive => [ 'default-src' => ['self'], - 'script-src' => ['self', 'unsafe-inline'], + 'script-src' => ['self', 'unsafe-inline', 'unsafe-eval'], 'style-src' => ['self', 'unsafe-inline'], 'img-src' => ['self', 'data:'], 'font-src' => ['self'], + 'connect-src' => ['self', 'https:'], 'object-src' => ['none'], 'base-uri' => ['self'], + 'form-action' => ['self'], 'frame-ancestors' => ['self'], 'upgrade-insecure-requests' => [], ], From a1e09f78a3bcff11a0aa7286b863b9700ef8d4d5 Mon Sep 17 00:00:00 2001 From: aubes <3941035+aubes@users.noreply.github.com> Date: Wed, 6 May 2026 23:04:25 +0200 Subject: [PATCH 2/2] Add CSPHeaderEvent, csp_script_hash and csp_style_hash --- CHANGELOG.md | 7 + README.md | 462 +++--------------------------- composer.json | 2 + docs/advanced.md | 98 +++++++ docs/configuration.md | 164 +++++++++++ docs/getting-started.md | 123 ++++++++ docs/reporting.md | 167 +++++++++++ docs/twig.md | 146 ++++++++++ src/CSPBundle.php | 1 + src/Enum/CSPSource.php | 3 + src/Event/CSPHeaderEvent.php | 20 ++ src/Listener/CSPListener.php | 11 +- src/Twig/CSPExtension.php | 2 + src/Twig/CSPInlineNode.php | 13 +- src/Twig/CSPInlineTokenParser.php | 13 +- tests/Listener/ListenerTest.php | 29 +- 16 files changed, 818 insertions(+), 443 deletions(-) create mode 100644 docs/advanced.md create mode 100644 docs/configuration.md create mode 100644 docs/getting-started.md create mode 100644 docs/reporting.md create mode 100644 docs/twig.md create mode 100644 src/Event/CSPHeaderEvent.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 524df46..d94207d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `csp:check` no longer reports `'unsafe-inline'` as an error when `'strict-dynamic'` is present in the same directive (CSP Level 3 browsers ignore `'unsafe-inline'` in that case, so it's a CSP1/2 fallback, not a vulnerability). +### Added + +- **`CSPHeaderEvent`**: dispatched on every response after active groups are resolved and before headers are rendered. Listeners can mutate the active `CSPPolicy` instances to apply cross-cutting changes. See `docs/advanced.md#cspheaderevent`. +- **CSP Level 3 hash-reporting sources**: `'report-sha256'`, `'report-sha384'`, `'report-sha512'` added to the `CSPSource` enum. Use them in `script-src` / `style-src` to ask browsers to include the SHA hash of the blocked resource in violation reports (useful for iterating a `strict-dynamic` policy). +- **Hash block tags**: `{% csp_script_hash %}...{% end_csp_script_hash %}` and `{% csp_style_hash %}...{% end_csp_style_hash %}` capture the inline content, compute its sha256 hash, and add it to the corresponding directive. No `nonce` attribute on the rendered tag, so the page stays cache-friendly. See `docs/twig.md#hash-block-tags`. + ## [2.0.0] ### Breaking changes @@ -62,5 +68,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `symfony/twig-bundle` is now optional: install it explicitly if you use nonce/hash Twig helpers +[Unreleased]: https://github.com/aubes/csp-bundle/compare/v2.1.0...HEAD [2.1.0]: https://github.com/aubes/csp-bundle/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/aubes/csp-bundle/compare/v1.0.0...v2.0.0 diff --git a/README.md b/README.md index a3d4c7a..eaa9761 100644 --- a/README.md +++ b/README.md @@ -5,467 +5,73 @@ [![PHP Version](https://img.shields.io/packagist/dependency-v/aubes/csp-bundle/php)](https://packagist.org/packages/aubes/csp-bundle) [![License](https://img.shields.io/packagist/l/aubes/csp-bundle)](https://packagist.org/packages/aubes/csp-bundle) -Symfony bundle for managing [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers. +A Symfony bundle that makes [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers simple to manage. -- **Presets** to get started in seconds (`strict`, `permissive`, `api`) -- **Nonces and hashes** via Twig helpers and block tags -- **Multi-group policies** with per-controller PHP attributes -- **Audit command** (`csp:check`) to detect misconfigurations -- **Violation reporting** with Reporting-Endpoints and legacy Report-To support -- **Worker-ready** (FrankenPHP / RoadRunner) +CSP headers protect your users from XSS, clickjacking, and data injection attacks. -**Requirements:** PHP >= 8.2, Symfony ^6.4 | ^7.4 | ^8.0 +## What it does -## Installation +- [**Presets**](docs/configuration.md#presets) to get started in seconds: `strict`, `permissive`, or `api` +- [**Nonces and hashes**](docs/twig.md) via Twig helpers, no manual header manipulation +- [**Per-controller policies**](docs/getting-started.md#per-controller-policies) with PHP attributes (`#[CSPGroup]`, `#[CSPDisabled]`) +- [**Audit command**](docs/reporting.md#audit-command) to catch misconfigurations before they reach production +- [**Violation reporting**](docs/reporting.md) with modern `Reporting-Endpoints` and legacy `Report-To` +- [**Gradual rollout**](docs/reporting.md#gradual-rollout): enforce a permissive policy today, evaluate a strict one in report-only mode, switch when ready +- [**Dynamic directives**](docs/advanced.md#dynamic-directives) at runtime from controllers +- [**Worker-ready**](docs/advanced.md#worker-mode-frankenphp--roadrunner): FrankenPHP, RoadRunner -```shell -composer require aubes/csp-bundle -``` +## Requirements -For Twig nonce/hash support: +- PHP >= 8.2 +- Symfony ^6.4 \| ^7.4 \| ^8.0 + +## Quick start ```shell -composer require symfony/twig-bundle +composer require aubes/csp-bundle ``` -## Configuration - ```yaml # config/packages/csp.yaml csp: - # Required when multiple groups are defined - # When only one group is defined, it becomes the default - default_group: default - - # Automatically add default group CSP headers to every response auto_default: true - - # Force report-only mode on all groups (useful in dev) - debug: false - groups: default: - # Use a preset as a base (strict, permissive, api) preset: strict - - # Report-Only instead of enforcing - report_only: false - - # Additional policies (merged with preset) - policies: - connect_src: - - self - - 'https://api.example.com' - - admin: - preset: permissive - policies: - img_src: - - self - - 'https://cdn.example.com' - - api: - preset: api -``` - -### Presets - -Three built-in presets provide sensible defaults. Preset policies are merged with your custom policies: your policies extend the preset, they don't replace it. - -#### `strict` - -Nonce-based policy with `strict-dynamic`. Recommended starting point when your templates can inject nonces on inline scripts and styles. - -```text -default-src 'self' -script-src 'strict-dynamic' 'unsafe-inline' https: -style-src 'self' -object-src 'none' -base-uri 'none' -form-action 'self' -frame-ancestors 'self' -upgrade-insecure-requests ``` -> **Important:** `strict-dynamic` requires nonces to work. Modern browsers (CSP Level 3) ignore the `'unsafe-inline'` / `https:` fallbacks once `'strict-dynamic'` is enforced, so without nonces all your inline scripts will be blocked there. Use the Twig nonce helpers in your templates: -> -> ```twig -> {# Block tag (recommended) #} -> {% csp_script %} -> document.getElementById('app').textContent = 'Hello!'; -> {% end_csp_script %} -> -> {# Or manual nonce attribute #} -> -> ``` - -#### `permissive` - -Allows `unsafe-inline` and `unsafe-eval`. Designed for legacy apps that cannot adopt nonces yet, but still want defense in depth. - -```text -default-src 'self' -script-src 'self' 'unsafe-inline' 'unsafe-eval' -style-src 'self' 'unsafe-inline' -img-src 'self' data: -font-src 'self' -connect-src 'self' https: -object-src 'none' -base-uri 'self' -form-action 'self' -frame-ancestors 'self' -upgrade-insecure-requests -``` - -This preset is **less secure than `strict`** (no XSS protection from inline scripts), but it's a reasonable baseline while you incrementally add nonces and migrate to `strict`. Pair it with a [gradual rollout](#gradual-rollout). - -#### `api` - -Locks everything down. Designed for JSON APIs and other endpoints that don't render HTML. - -```text -default-src 'none' -frame-ancestors 'none' -base-uri 'none' -form-action 'none' -``` - -Apply this preset to your API controllers via `#[CSPGroup('api')]` or a route default. - -### Directive names - -Use underscore-separated names in YAML configuration: - -```yaml -policies: - script_src: [self] - style_src_elem: [self] - frame_ancestors: [self] - upgrade_insecure_requests: [] -``` - -## Usage - -### Applying CSP headers - -#### Auto default - -With `auto_default: true`, the default group is applied to every response automatically. - -#### PHP attributes +That's it. Every response now has a strict Content-Security-Policy header with nonce support. -Use attributes on controllers to select groups or disable CSP: - -```php -use Aubes\CSPBundle\Attribute\CSPGroup; -use Aubes\CSPBundle\Attribute\CSPDisabled; - -// Apply a specific group -#[CSPGroup('admin')] -class AdminController extends AbstractController -{ - // All actions use the "admin" CSP group -} - -// Apply one enforcing + one report-only group -#[CSPGroup('default')] -#[CSPGroup('strict_ro')] -class DashboardController extends AbstractController {} - -// Override at method level -class PageController extends AbstractController -{ - #[CSPGroup('strict')] - public function secure(): Response {} -} - -// Disable CSP entirely -#[CSPDisabled] -class WebhookController extends AbstractController {} -``` - -> **Multi-group constraint:** each request supports at most one enforcing group and one report-only group. Applying two groups of the same mode (e.g. two enforcing groups) will throw a `LogicException`. This is by design: the HTTP spec says multiple `Content-Security-Policy` headers are intersected (most restrictive wins), which would silently break additive policies. - -#### Route defaults - -You can also configure groups via route defaults: - -```yaml -# config/routes.yaml -admin_route: - path: /admin - defaults: - _csp_groups: [admin] - -webhook_route: - path: /webhook - defaults: - _csp_disabled: true -``` - -#### Dynamic directives - -Add directives at runtime from a controller: - -```php -use Aubes\CSPBundle\CSP; - -class ExampleController extends AbstractController -{ - public function __invoke(CSP $csp): Response - { - // Add to default group - $csp->addDirective('script-src', 'https://cdn.example.com'); - - // Add to a specific group - $csp->addDirective('img-src', 'https://images.example.com', 'admin'); - - return $this->render('example.html.twig'); - } -} -``` - -### Twig: nonces - -Nonces are generated per-request and automatically added to the CSP header. - -#### Block tags (recommended) - -Wraps your inline script/style with a nonce automatically: +Add nonces to your inline scripts: ```twig {% csp_script %} document.getElementById('app').textContent = 'Hello!'; {% end_csp_script %} - -{% csp_style %} - body { font-family: sans-serif; } -{% end_csp_style %} - -{# With a specific group #} -{% csp_script 'admin' %} - console.log('admin'); -{% end_csp_script %} ``` -#### Functions - -For manual nonce attributes: - -```twig -{# script-src nonce #} - - -{# style-src nonce #} - - -{# Generic nonce with directive #} - - -{# With a specific group #} - -``` - -### Twig: hashes - -Register a hash for inline content without using a nonce: - -```twig -{% do csp_hash('script-src', "alert('hello')") %} - -{# With a specific algorithm (default: sha256) #} -{% do csp_hash('script-src', content, 'sha384') %} - -{# With a specific group #} -{% do csp_hash('script-src', content, 'sha256', 'admin') %} -``` - -## Reporting - -### Configuration - -Enable [Reporting-Endpoints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Reporting-Endpoints) (modern) and optionally [Report-To](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Report-To) (legacy): - -```yaml -# config/packages/csp.yaml -csp: - groups: - default: - reporting: - max_age: 3600 - # Optional: override the group name in the report-to directive - group_name: ~ - # Emit legacy Report-To header alongside Reporting-Endpoints - backward_compatibility: false - endpoints: - - csp_report # Symfony route name -``` - -> **Note:** Multiple endpoints can be configured, but only the first one is used with the modern `Reporting-Endpoints` header (which supports a single URL per group). Additional endpoints are only used when `backward_compatibility: true` is enabled, as the legacy `Report-To` header supports failover across multiple URLs. - -### Built-in report controller - -A controller is included to receive CSP violation reports (path: `/csp-report/{group}`, route: `csp_report`). Each report is dispatched as a `CSPViolationEvent`. - -Import the bundle's routing: - -```yaml -# config/routes.yaml -csp_report: - resource: '@CSPBundle/Resources/config/routing.yaml' -``` - -Or define the route manually: - -```yaml -# config/routes.yaml -csp_report: - path: /csp-report/{group} - controller: Aubes\CSPBundle\Controller\ReportController::__invoke - methods: ['POST'] -``` - -### Handling violation reports - -Listen to `CSPViolationEvent` to handle reports however you want (log, Sentry, database, Slack, etc.): - -```php -use Aubes\CSPBundle\Event\CSPViolationEvent; -use Symfony\Component\EventDispatcher\Attribute\AsEventListener; - -#[AsEventListener] -class CSPViolationListener -{ - public function __invoke(CSPViolationEvent $event): void - { - // $event->group : the CSP group name - // $event->report : the sanitized report data - } -} -``` - -### Built-in logger - -Optionally enable the built-in log listener to log violations via Monolog: - -```yaml -# config/packages/csp.yaml -csp: - report_logger: - logger_id: ~ # Logger service ID (default: logger) - level: ~ # Log level (default: WARNING) -``` - -Without `report_logger`, events are dispatched but not logged. - -## Audit command - -Audit your CSP configuration against common security pitfalls: +For Twig nonce/hash support, install `symfony/twig-bundle`: ```shell -php bin/console csp:check -``` - -The command checks for: missing critical directives, `unsafe-inline`/`unsafe-eval` usage, wildcard sources, HTTP/IP sources, `strict-dynamic` without nonce, trusted types consistency, and missing reporting. - -``` - ------- ------- ----------- ----------------------------------------------------------- - Level Group Directive Finding - ------- ------- ----------- ----------------------------------------------------------- - ERROR weak script-src 'unsafe-inline' allows execution of arbitrary inline scripts. - WARN weak object-src object-src should be 'none' unless plugins are explicitly needed. - INFO default report-to No reporting endpoint configured. - ------- ------- ----------- ----------------------------------------------------------- -``` - -## Web Debug Toolbar - -When `symfony/web-profiler-bundle` is installed, a CSP panel is available in the Symfony profiler showing: -- Active group(s) and enabled/disabled status -- Full CSP header value -- Parsed directives with color-coded sources (keywords, nonces, hashes, URLs) -- Report-Only header if present -- Reporting endpoints - -## Debug mode - -Set `debug: true` to force all groups into `report-only` mode. Useful during development to detect violations without breaking the page: - -```yaml -# config/packages/csp.yaml -when@dev: - csp: - debug: true -``` - -## Gradual rollout - -Hardening your CSP without breaking your site is easy with multi-group policies. The idea: enforce a permissive policy today, evaluate a strict one in parallel, then switch when ready. - -### Step 1: enforce permissive, evaluate strict - -```yaml -csp: - default_group: default - groups: - default: - preset: permissive - - strict: - preset: strict - report_only: true - reporting: - max_age: 3600 - endpoints: - - csp_report +composer require symfony/twig-bundle ``` -Apply both groups to your controllers: - -```php -#[CSPGroup('default')] -#[CSPGroup('strict')] -class MyController extends AbstractController {} -``` +## Why CSP matters -The `permissive` group enforces (nothing breaks), while `strict` runs in report-only mode. Violations are sent to your reporting endpoint. +Without CSP, any injected script runs with full page privileges. A single XSS vulnerability can steal cookies, redirect users, or exfiltrate data. -### Step 2: fix violations +CSP tells the browser exactly which sources are allowed. Everything else is blocked. It's the last line of defense when your input validation or output encoding has a gap. -Review reports and add nonces to your inline scripts and styles: +## Documentation -```twig -{% csp_script %} - document.getElementById('app').textContent = 'Hello!'; -{% end_csp_script %} -``` - -### Step 3: switch to strict - -Once reports are clean, promote `strict` to enforcing and remove the permissive group: - -```yaml -csp: - default_group: default - groups: - default: - preset: strict - reporting: - max_age: 3600 - endpoints: - - csp_report -``` +- [Getting started](docs/getting-started.md): installation, first policy, nonces, per-controller attributes +- [Configuration](docs/configuration.md): presets, directives, debug mode, worker support +- [Twig helpers](docs/twig.md): block tags, nonce functions, hashes +- [Reporting](docs/reporting.md): violation endpoints, audit command, gradual rollout +- [Troubleshooting](docs/troubleshooting.md): common errors and fixes +- [Advanced](docs/advanced.md): dynamic directives, route defaults, worker mode, profiler +- [Upgrading from 1.x to 2.0](UPGRADE-2.0.md): breaking changes and migration steps -## Worker mode (FrankenPHP / RoadRunner) +## License -The `CSP` service implements `ResetInterface`. Nonces and dynamic directives are automatically reset between requests in long-running processes. +[MIT](LICENSE) diff --git a/composer.json b/composer.json index 9d64c37..082ce7c 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,8 @@ "require": { "php": ">=8.2", "psr/log": "^2.0 | ^3.0", + "symfony/event-dispatcher": "^6.4 | ^7.4 | ^8.0", + "symfony/event-dispatcher-contracts": "^3.0", "symfony/framework-bundle": "^6.4 | ^7.4 | ^8.0", "symfony/http-foundation": "^6.4 | ^7.4 | ^8.0", "symfony/http-kernel": "^6.4 | ^7.4 | ^8.0", diff --git a/docs/advanced.md b/docs/advanced.md new file mode 100644 index 0000000..2bbaf42 --- /dev/null +++ b/docs/advanced.md @@ -0,0 +1,98 @@ +# Advanced + +## Dynamic directives + +Add directives at runtime from a controller: + +```php +use Aubes\CSPBundle\CSP; + +class ExampleController extends AbstractController +{ + public function __invoke(CSP $csp): Response + { + // Add to the default group + $csp->addDirective('script-src', 'https://cdn.example.com'); + + // Add to a specific group + $csp->addDirective('img-src', 'https://images.example.com', 'admin'); + + return $this->render('example.html.twig'); + } +} +``` + +This is useful when a directive depends on runtime data (e.g. a CDN URL from a CMS, an iframe source from user settings). + +## CSPHeaderEvent + +For cross-cutting changes, listen to `CSPHeaderEvent`. It is dispatched on every response, after the active groups are resolved and before the headers are rendered. Listeners can mutate the active `CSPPolicy` instances directly. + +```php +use Aubes\CSPBundle\Event\CSPHeaderEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +class TenantCSPListener +{ + #[AsEventListener] + public function onCspHeader(CSPHeaderEvent $event): void + { + $tenant = $event->request->attributes->get('_tenant'); + if ($tenant === null) { + return; + } + + foreach ($event->policies as $policy) { + $policy->addPolicy('connect-src', \sprintf('https://%s.api.example.com', $tenant)); + } + } +} +``` + +The event exposes: + +- `request` (`Symfony\Component\HttpFoundation\Request`): the current main request +- `policies` (`array`): active policies for this request, keyed by group name + +Mutations apply to the response being built. They do not persist across requests (the bundle resets policies between requests via `ResetInterface`). + +> **Note:** the event is not dispatched when no policy is active (CSP disabled, sub-request, report route). + +## Route defaults + +As an alternative to PHP attributes, you can configure CSP groups directly in your route definitions: + +```yaml +# config/routes.yaml +admin_route: + path: /admin + defaults: + _csp_groups: [admin] + +webhook_route: + path: /webhook + defaults: + _csp_disabled: true +``` + +This works the same as `#[CSPGroup('admin')]` and `#[CSPDisabled]` on controllers. + +> **Note:** PHP attributes and route defaults are additive. When both are present, their groups are merged (duplicates removed). Use one or the other to avoid surprises. + +## Web Debug Toolbar + +When `symfony/web-profiler-bundle` is installed, a CSP panel appears in the Symfony profiler showing: + +- Active group(s) and enabled/disabled status +- Full CSP header value +- Parsed directives with color-coded sources (keywords, nonces, hashes, URLs) +- Report-Only header if present +- Reporting endpoints + +No configuration needed: the panel registers automatically when the profiler bundle is available. + +## Worker mode (FrankenPHP) + +The `CSP` service implements Symfony's `ResetInterface`. Nonces, dynamic directives, and group selections are automatically cleared between requests in long-running processes. + +No configuration needed: the reset is handled by Symfony's `services_resetter`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a9c9c4a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,164 @@ +# Configuration + +## Full reference + +```yaml +# config/packages/csp.yaml +csp: + # Required when multiple groups are defined. + # When only one group exists, it becomes the default automatically. + default_group: default + + # Apply the default group to every response without needing attributes. + # Default: false (opt-in). + auto_default: true + + # Force all groups into report-only mode (useful in dev). + debug: false + + # Optional: log violations via Monolog. + report_logger: + logger_id: ~ # Service ID (default: logger) + level: ~ # Log level (default: WARNING) + + groups: + default: + # Base preset: strict, permissive, or api. + preset: strict + + # Send the header as Report-Only instead of enforcing. + report_only: false + + # Your custom directives (merged with preset). + policies: + connect_src: + - self + - 'https://api.example.com' + img_src: + - self + + # Violation reporting. + reporting: + max_age: 3600 + group_name: ~ # Override the report-to group name + backward_compatibility: false # Emit legacy Report-To header + endpoints: + - csp_report # Symfony route name +``` + +## Presets + +Presets provide sensible defaults. Your custom `policies` are merged on top (they extend, never replace). + +### `strict` + +Nonce-based policy with `strict-dynamic`. Recommended as a starting point when your templates can inject nonces on inline scripts and styles. + +```text +default-src 'self' +script-src 'strict-dynamic' 'unsafe-inline' https: +style-src 'self' +object-src 'none' +base-uri 'none' +form-action 'self' +frame-ancestors 'self' +upgrade-insecure-requests +``` + +> **Important:** `strict-dynamic` requires nonces. Without the [Twig nonce helpers](twig.md), all inline scripts are blocked. + +> **Browser support:** `strict-dynamic` is part of CSP Level 3 and supported by modern Chromium, Firefox, and Safari. Older browsers ignore it and fall back to the rest of the source list, so pair it with `'self'` or a host allowlist if you need graceful degradation. See [caniuse.com/mdn-http_headers_content-security-policy_strict-dynamic](https://caniuse.com/mdn-http_headers_content-security-policy_strict-dynamic) for current support. + +> **About `'unsafe-inline'` and `https:`:** these are deliberate fallbacks for CSP Level 1/2 browsers that don't understand `'strict-dynamic'`. CSP Level 3 browsers ignore both when `'strict-dynamic'` is present, so the policy stays strict where it counts. + +### `permissive` + +Allows `unsafe-inline` for scripts and styles. Suitable for legacy apps that cannot adopt nonces yet. + +```text +default-src 'self' +script-src 'self' 'unsafe-inline' 'unsafe-eval' +style-src 'self' 'unsafe-inline' +img-src 'self' data: +font-src 'self' +connect-src 'self' https: +object-src 'none' +base-uri 'self' +form-action 'self' +frame-ancestors 'self' +upgrade-insecure-requests +``` + +### `api` + +Locks everything down. Designed for JSON APIs with no HTML rendering. + +```text +default-src 'none' +frame-ancestors 'none' +base-uri 'none' +form-action 'none' +``` + +## Directive names + +In YAML configuration, use **underscores** instead of hyphens: + +```yaml +policies: + script_src: [self] + style_src_elem: [self] + frame_ancestors: [self] + upgrade_insecure_requests: [] +``` + +All [CSP Level 3 directives](https://www.w3.org/TR/CSP3/) are supported: + +| Directive | YAML key | +|---|---| +| `default-src` | `default_src` | +| `script-src` | `script_src` | +| `script-src-elem` | `script_src_elem` | +| `script-src-attr` | `script_src_attr` | +| `style-src` | `style_src` | +| `style-src-elem` | `style_src_elem` | +| `style-src-attr` | `style_src_attr` | +| `img-src` | `img_src` | +| `font-src` | `font_src` | +| `connect-src` | `connect_src` | +| `media-src` | `media_src` | +| `object-src` | `object_src` | +| `frame-src` | `frame_src` | +| `child-src` | `child_src` | +| `worker-src` | `worker_src` | +| `manifest-src` | `manifest_src` | +| `base-uri` | `base_uri` | +| `form-action` | `form_action` | +| `frame-ancestors` | `frame_ancestors` | +| `upgrade-insecure-requests` | `upgrade_insecure_requests` | +| `require-trusted-types-for` | `require_trusted_types_for` | +| `trusted-types` | `trusted_types` | +| `webrtc` | `webrtc` | + +> **Browser support:** Trusted Types (`require-trusted-types-for`, `trusted-types`) and `webrtc` have partial or experimental support. Non-supporting browsers ignore them silently (graceful degradation), so you cannot rely on them for cross-browser protection. Check [caniuse.com](https://caniuse.com/) for each directive before relying on it in production. + +## Debug mode + +Force all groups into report-only mode during development: + +```yaml +# config/packages/csp.yaml +when@dev: + csp: + debug: true +``` + +Violations are reported in the browser console without blocking anything. Combine with `report_logger` to see them in your Symfony logs. + +## Multi-group constraint + +Each request supports at most **one enforcing group** and **one report-only group**. Applying two groups of the same mode throws a `LogicException`. + +This is by design: the HTTP spec says multiple `Content-Security-Policy` headers are intersected (most restrictive wins), which would silently break additive policies. + +The typical multi-group use case is [gradual rollout](reporting.md#gradual-rollout): enforce a permissive policy while evaluating a strict one in report-only mode. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..5eb55cd --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,123 @@ +# Getting started + +## Installation + +```shell +composer require aubes/csp-bundle +``` + +For Twig nonce/hash support: + +```shell +composer require symfony/twig-bundle +``` + +## Your first policy + +Create a minimal configuration: + +```yaml +# config/packages/csp.yaml +csp: + auto_default: true + groups: + default: + preset: strict +``` + +The `strict` preset gives you: +- `script-src` with `strict-dynamic` (nonce-based) +- `object-src 'none'` +- `base-uri 'none'` +- `frame-ancestors 'self'` + +Every response now includes a `Content-Security-Policy` header. + +## Add nonces to your templates + +The `strict` preset relies on `strict-dynamic` for scripts, so inline ` +``` + +> **Note:** See the [Twig helpers](twig.md) page for the full list of functions: hashes, group targeting, and more. + +## Start in report-only mode + +Not sure your policy won't break anything? Use report-only mode first: + +```yaml +csp: + auto_default: true + groups: + default: + preset: strict + report_only: true + reporting: + max_age: 3600 + endpoints: + - csp_report +``` + +Import the built-in report route: + +```yaml +# config/routes.yaml +csp_report: + resource: '@CSPBundle/Resources/config/routing.yaml' +``` + +The browser reports violations instead of blocking them. Check your logs to see what would break, fix it, then switch `report_only` to `false`. + +## Per-controller policies + +Use PHP attributes to apply different policies per controller: + +```php +use Aubes\CSPBundle\Attribute\CSPGroup; +use Aubes\CSPBundle\Attribute\CSPDisabled; + +#[CSPGroup('admin')] +class AdminController extends AbstractController {} + +#[CSPDisabled] +class WebhookController extends AbstractController {} +``` + +> **Warning:** Each request supports at most one enforcing group and one report-only group. Applying two groups of the same mode throws a `LogicException`. See [multi-group constraint](configuration.md#multi-group-constraint) for details. + +## Audit your configuration + +Run the built-in audit command to catch common mistakes: + +```shell +php bin/console csp:check +``` + +It checks for missing directives, unsafe sources, wildcards, and more. + +## Next steps + +- [Twig helpers](twig.md): nonces, hashes, block tags +- [Full configuration reference](configuration.md) +- [Reporting and violation handling](reporting.md) +- [Gradual rollout strategy](reporting.md#gradual-rollout) diff --git a/docs/reporting.md b/docs/reporting.md new file mode 100644 index 0000000..e51540b --- /dev/null +++ b/docs/reporting.md @@ -0,0 +1,167 @@ +# Reporting + +CSP violation reports tell you when the browser blocks (or would block) a resource. This is essential for detecting misconfigurations and monitoring real-world attacks. + +## Setup + +### 1. Configure reporting on your group + +```yaml +# config/packages/csp.yaml +csp: + groups: + default: + preset: strict + reporting: + max_age: 3600 + endpoints: + - csp_report # Symfony route name +``` + +This adds a `report-to` directive to your CSP header and a `Reporting-Endpoints` header to the response. + +> **Browser support:** `Reporting-Endpoints` is not yet universally supported. For broader coverage, enable the [legacy `Report-To`](#legacy-report-to-support) header in parallel. Check [caniuse.com/mdn-http_headers_reporting-endpoints](https://caniuse.com/mdn-http_headers_reporting-endpoints) for current status before relying on it. + +### 2. Import the built-in route + +```yaml +# config/routes.yaml +csp_report: + resource: '@CSPBundle/Resources/config/routing.yaml' +``` + +This registers a `POST /csp-report/{group}` endpoint that receives violation reports from browsers. + +### 3. Handle violations + +Each report dispatches a `CSPViolationEvent`. Listen to it however you want: + +```php +use Aubes\CSPBundle\Event\CSPViolationEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[AsEventListener] +class CSPViolationListener +{ + public function __invoke(CSPViolationEvent $event): void + { + // $event->group : the CSP group name + // $event->report : the sanitized report data + } +} +``` + +Send violations to Sentry, a database, Slack, or wherever makes sense for your team. + +### Built-in logger (optional) + +Don't want to write a listener? Enable the built-in logger: + +```yaml +csp: + report_logger: + logger_id: ~ # defaults to "logger" + level: ~ # defaults to WARNING +``` + +Violations are logged via Monolog. Without this config, events are dispatched but not logged. + +## Legacy Report-To support + +The modern `Reporting-Endpoints` header supports one URL per endpoint. If you need failover across multiple URLs, enable the legacy `Report-To` header: + +```yaml +reporting: + backward_compatibility: true + endpoints: + - csp_report + - csp_report_fallback +``` + +> **Note:** Multiple endpoints can be configured, but only the first one is used with the modern `Reporting-Endpoints` header. Additional endpoints are only used when `backward_compatibility: true` is enabled, as the legacy `Report-To` header supports failover across multiple URLs. + +## Gradual rollout + +Hardening your CSP without breaking your site. The idea: enforce a permissive policy today, evaluate a strict one in parallel, then switch when ready. + +### Step 1: enforce permissive, evaluate strict + +```yaml +csp: + default_group: default + groups: + default: + preset: permissive + + strict: + preset: strict + report_only: true + reporting: + max_age: 3600 + endpoints: + - csp_report +``` + +Apply both groups on your controllers: + +```php +#[CSPGroup('default')] +#[CSPGroup('strict')] +class MyController extends AbstractController {} +``` + +The browser enforces the `permissive` policy (nothing breaks) and reports violations against the `strict` policy. Two separate headers, two different behaviors. + +### Step 2: fix violations + +Review reports and add nonces to inline scripts and styles: + +```twig +{% csp_script %} + document.getElementById('app').textContent = 'Hello!'; +{% end_csp_script %} +``` + +See [Twig helpers](twig.md) for the full list of available functions. + +### Step 3: switch to strict + +Once reports are clean, promote `strict` to enforcing and remove the permissive group: + +```yaml +csp: + default_group: default + groups: + default: + preset: strict + reporting: + max_age: 3600 + endpoints: + - csp_report +``` + +> **Important:** Keep reporting enabled after switching to enforce mode. Browser extensions, third-party scripts, and user-specific flows can trigger violations your test environment will not reproduce. + +## Audit command + +Catch common mistakes before deploying: + +```shell +php bin/console csp:check +``` + +The command checks for: missing critical directives, `unsafe-inline`/`unsafe-eval` usage, wildcard sources, HTTP/IP sources, `strict-dynamic` without nonce, trusted types consistency, and missing reporting. + +```text + ------- ------- ----------- ----------------------------------------------------------- + Level Group Directive Finding + ------- ------- ----------- ----------------------------------------------------------- + ERROR weak script-src 'unsafe-inline' allows execution of arbitrary inline scripts. + WARN weak object-src object-src should be 'none' unless plugins are explicitly needed. + INFO default report-to No reporting endpoint configured. + ------- ------- ----------- ----------------------------------------------------------- + +Found 1 error(s), 1 warning(s), 1 info(s) +``` + +The command exits with code `1` if any finding is at `ERROR` level, making it CI-friendly. diff --git a/docs/twig.md b/docs/twig.md new file mode 100644 index 0000000..d077535 --- /dev/null +++ b/docs/twig.md @@ -0,0 +1,146 @@ +# Twig helpers + +The bundle provides Twig functions and block tags to add nonces and hashes to your CSP policies. They require `symfony/twig-bundle`: + +```shell +composer require symfony/twig-bundle +``` + +## Block tags (recommended) + +Block tags wrap your inline code in a ` +``` + +### Targeting a specific group + +```twig +{% csp_script 'admin' %} + console.log('admin panel'); +{% end_csp_script %} + +{% csp_style 'admin' %} + .sidebar { width: 250px; } +{% end_csp_style %} +``` + +## Nonce functions + +For cases where you need the nonce attribute directly (external scripts, custom tags): + +```twig +{# script-src nonce #} + + +{# style-src nonce #} + + +{# Generic nonce with any directive #} + +``` + +### Targeting a specific group + +Pass the group name as the first (or second) argument: + +```twig + + + + + +``` + +> **Note:** Each call generates a fresh nonce per request. The nonce is automatically added to the corresponding directive in the CSP header. + +## Hash block tags + +When the inline content is static, prefer hashes over nonces: the page can be cached publicly because the hash never changes (a nonce changes per request and breaks shared caches). + +```twig +{% csp_script_hash %} + alert('hello') +{% end_csp_script_hash %} + +{% csp_style_hash %} + body { font-family: sans-serif; } +{% end_csp_style_hash %} +``` + +Rendered output (no `nonce` attribute): + +```html + +``` + +The bundle captures the content, computes its sha256 hash, and adds `'sha256-...'` to the relevant directive automatically. + +### Targeting a specific group + +```twig +{% csp_script_hash 'admin' %} + console.log('admin panel'); +{% end_csp_script_hash %} +``` + +### `csp_hash` function + +For external resources or cases where the content is not directly inside the template: + +```twig +{% do csp_hash('script-src', "alert('hello')") %} + +``` + +Options: + +```twig +{# Custom algorithm (default: sha256) #} +{% do csp_hash('script-src', content, 'sha384') %} + +{# Targeting a specific group #} +{% do csp_hash('script-src', content, 'sha256', 'admin') %} +``` + +> **Warning:** The hash must match the content exactly, including whitespace. If you edit the inline code without updating the hash, the browser blocks it until the next page load regenerates the header. The block tags handle this automatically; the `csp_hash` function does not. + +## Block tags vs nonce functions vs hashes + +| Approach | Best for | Pros | +|---|---|---| +| Nonce block tags (`csp_script`, `csp_style`) | Inline scripts/styles that may change per request | Simplest, nonce handled automatically | +| Hash block tags (`csp_script_hash`, `csp_style_hash`) | Inline scripts/styles that are stable across requests | Cache-friendly (no per-request nonce), no `nonce` attribute in HTML | +| Nonce functions | External `