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] 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' => [], ],