From 72bbd6f54ab520b7d77581eb90e835af7679d273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 14:40:05 +0100 Subject: [PATCH 01/23] Implement Csp --- src/Common/Csp.php | 170 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/Common/Csp.php diff --git a/src/Common/Csp.php b/src/Common/Csp.php new file mode 100644 index 000000000..f4a99ac33 --- /dev/null +++ b/src/Common/Csp.php @@ -0,0 +1,170 @@ +> The directives and their values + */ + protected array $directives = []; + + /** + * @var string|null The first nonce found in the directives. + * Note: This assumes there will only ever be one nonce. + */ + protected ?string $nonce = null; + + /** + * Create a new CSP by merging multiple CSPs. + * + * @param Csp ...$csps The CSPs to merge + * + * @return static A new CSP containing all directives from the input CSPs + */ + public static function merge(Csp ...$csps): static + { + $result = new static(); + foreach ($csps as $csp) { + foreach ($csp->directives as $directive => $values) { + if ($directive === 'default-src') { + continue; + } + $result->add($directive, $values); + } + } + return $result; + } + + /** + * Create a new CSP from a string + * + * @param string $header The CSP header string + * + * @return static A new CSP containing all directives from the input header + */ + public static function fromString(string $header): static + { + $result = new static(); + foreach (explode(';', $header) as $directive) { + $directive = trim($directive); + if (empty($directive) || $directive === 'default-src') { + continue; + } + $parts = explode(' ', $directive, 2); + $result->add($parts[0], $parts[1] ?? ''); + } + + return $result; + } + + /** + * Add a directive with a policy or a list of policies to the CSP + * + * @param string $directive The directive name + * @param string|array $value The policy or list of policies to add + * + * @return $this + */ + public function add(string $directive, string|array $value): static + { + if ($directive === "default-src") { + throw new InvalidArgumentException("Changing default-src is forbidden."); + } + + if (! preg_match('|^[a-z\-]+$|', $directive)) { + throw new InvalidArgumentException( + "Directive names contain only lowercase letters and '-'. Directive: $directive", + ); + } + + if (is_string($value)) { + $value = trim($value); + + if (str_contains($value, ' ')) { + return $this->add($directive, explode(' ', $value)); + } + + if (! isset($this->directives[$directive])) { + $this->directives[$directive] = []; + } + + if (in_array($value, $this->directives[$directive])) { + return $this; + } + + $this->directives[$directive][] = $value; + + if ( + $this->nonce === null + && str_starts_with($value, "'nonce-") + && str_ends_with($value, "'") + ) { + $this->nonce = substr($value, 7, -1); + } + } else { + foreach ($value as $v) { + $this->add($directive, $v); + } + } + + return $this; + } + + /** + * @return string|null The first nonce found in the directives. + */ + public function getNonce(): ?string + { + return $this->nonce; + } + + /** + * Get the values of a directive + * + * @param string $directive The directive name + * + * @return Generator + */ + public function getDirective(string $directive): Generator + { + yield from $this->directives[$directive] ?? []; + } + + /** + * Get all directives + * + * @return Generator> + */ + public function getDirectives(): Generator + { + yield from $this->directives; + } + + /** + * Get the fully formated CSP header string. + * This can be used directly in the Content-Security-Policy header. + * + * @return string The CSP header string + */ + public function getHeader(): string + { + $directiveStrings = ["default-src 'self'"]; + foreach ($this->directives as $directive => $values) { + $directiveStrings[] = sprintf('%s %s', $directive, implode(' ', $values)); + } + return implode('; ', $directiveStrings); + } + + public function __toString(): string + { + return $this->getHeader(); + } +} From 80c996d5b1cc65428c16ad2db895084cd71c612f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 23 Mar 2026 12:52:19 +0100 Subject: [PATCH 02/23] Allow newlines in Csp::fromString This is not allowed in the specification but it allows for more organized configuration files. --- src/Common/Csp.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index f4a99ac33..10e3e6e60 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -52,6 +52,9 @@ public static function merge(Csp ...$csps): static */ public static function fromString(string $header): static { + $header = trim($header); + $header = str_replace("\r\n", ' ', $header); + $header = str_replace("\n", ' ', $header); $result = new static(); foreach (explode(';', $header) as $directive) { $directive = trim($directive); From 1ae4773bb866a68916d0011f118f30b55f09b342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 24 Mar 2026 13:28:20 +0100 Subject: [PATCH 03/23] Review suggestions - Only add ploicy if we need to - Use `/` instead of `|` as regex delimiter - Return array instead of Generator --- src/Common/Csp.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 10e3e6e60..a1ffb47e6 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -2,7 +2,6 @@ namespace ipl\Web\Common; -use Generator; use InvalidArgumentException; /** @@ -62,7 +61,10 @@ public static function fromString(string $header): static continue; } $parts = explode(' ', $directive, 2); - $result->add($parts[0], $parts[1] ?? ''); + if (count($parts) < 2) { + continue; + } + $result->add($parts[0], $parts[1]); } return $result; @@ -82,7 +84,7 @@ public function add(string $directive, string|array $value): static throw new InvalidArgumentException("Changing default-src is forbidden."); } - if (! preg_match('|^[a-z\-]+$|', $directive)) { + if (! preg_match('/^[a-z\-]+$/', $directive)) { throw new InvalidArgumentException( "Directive names contain only lowercase letters and '-'. Directive: $directive", ); @@ -134,21 +136,21 @@ public function getNonce(): ?string * * @param string $directive The directive name * - * @return Generator + * @return string[] */ - public function getDirective(string $directive): Generator + public function getDirective(string $directive): array { - yield from $this->directives[$directive] ?? []; + return $this->directives[$directive] ?? []; } /** * Get all directives * - * @return Generator> + * @return array> */ - public function getDirectives(): Generator + public function getDirectives(): array { - yield from $this->directives; + return $this->directives; } /** From cee77a5a34dee96a243082baba5bd2e744c8c410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 24 Mar 2026 13:33:16 +0100 Subject: [PATCH 04/23] Specify array type --- src/Common/Csp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index a1ffb47e6..e07d77a79 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -74,7 +74,7 @@ public static function fromString(string $header): static * Add a directive with a policy or a list of policies to the CSP * * @param string $directive The directive name - * @param string|array $value The policy or list of policies to add + * @param string|string[] $value The policy or list of policies to add * * @return $this */ From d18f30166aa5cd0d59105ff43891fd2d7115634a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 11:33:22 +0100 Subject: [PATCH 05/23] Add isEmpty method that checks if the array of directives is empty --- src/Common/Csp.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index e07d77a79..51b364180 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -172,4 +172,9 @@ public function __toString(): string { return $this->getHeader(); } + + public function isEmpty(): bool + { + return empty($this->directives); + } } From 53ec3d6d35c10cfc472f9ceeb5400118ce0ac44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 12:09:37 +0100 Subject: [PATCH 06/23] Add tests --- src/Common/Csp.php | 13 +++- tests/Common/CspTest.php | 162 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 tests/Common/CspTest.php diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 51b364180..e9cc4bbb7 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -57,7 +57,7 @@ public static function fromString(string $header): static $result = new static(); foreach (explode(';', $header) as $directive) { $directive = trim($directive); - if (empty($directive) || $directive === 'default-src') { + if (empty($directive)) { continue; } $parts = explode(' ', $directive, 2); @@ -97,6 +97,10 @@ public function add(string $directive, string|array $value): static return $this->add($directive, explode(' ', $value)); } + if (empty($value)) { + return $this; + } + if (! isset($this->directives[$directive])) { $this->directives[$directive] = []; } @@ -112,7 +116,12 @@ public function add(string $directive, string|array $value): static && str_starts_with($value, "'nonce-") && str_ends_with($value, "'") ) { - $this->nonce = substr($value, 7, -1); + $nonce = substr($value, 7, -1); + if (empty($nonce)) { + throw new InvalidArgumentException("Nonce cannot must have a value."); + } + + $this->nonce = $nonce; } } else { foreach ($value as $v) { diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php new file mode 100644 index 000000000..91aaca4a4 --- /dev/null +++ b/tests/Common/CspTest.php @@ -0,0 +1,162 @@ +assertInstanceOf(Csp::class, $csp); + $this->assertTrue($csp->isEmpty()); + + $csp->add('foo', 'bar'); + + $this->assertFalse($csp->isEmpty()); + } + + public function testAddString() + { + $csp = new Csp(); + + $csp->add('script-src', 'https://example.com'); + + $this->assertEquals(['https://example.com'], $csp->getDirective('script-src')); + } + + public function testAddStringEmpty() + { + $csp = new Csp(); + + $csp->add('script-src', ''); + + $this->assertTrue($csp->isEmpty()); + } + + public function testAddStringTrim() + { + $csp = new Csp(); + + $csp->add('script-src', ' https://example.com'); + + $this->assertEquals(['https://example.com'], $csp->getDirective('script-src')); + } + + public function testAddStringDuplicate() + { + $csp = new Csp(); + + $csp->add('script-src', 'https://example.com'); + $csp->add('script-src', 'https://example.com'); + + $this->assertEquals(['https://example.com'], $csp->getDirective('script-src')); + } + + public function testAddStringCombined() + { + $csp = new Csp(); + + $csp->add('script-src', 'https://example.com https://example.org'); + + $this->assertEquals(['https://example.com', 'https://example.org'], $csp->getDirective('script-src')); + } + + public function testAddArray() + { + $csp = new Csp(); + + $csp->add('img-src', ['https://example.com', 'https://example.org', 'https://example.com']); + + $this->assertEquals(['https://example.com', 'https://example.org'], $csp->getDirective('img-src')); + } + + public function testAddDefaultSource() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('default-src', 'https://example.com'); + } + + public function testAddDirectiveNameCapitals() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('Default-src', 'https://example.com'); + } + + public function testAddDirectiveNameSpecialCharacters() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('default-src:', 'https://example.com'); + } + + public function testGetDirectives() + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $csp->add('imc-src', "'self'"); + + $this->assertEquals( + ['script-src' => ['https://example.com'], 'imc-src' => ["'self'"]], + $csp->getDirectives(), + ); + } + + public function testGetHeader() + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $csp->add('imc-src', "'none'"); + + $this->assertEquals( + "default-src 'self'; script-src https://example.com; imc-src 'none'", + $csp->getHeader() + ); + + $this->assertEquals( + $csp->getHeader(), + (string) $csp, + ); + } + + public function testNonce() + { + $csp = new Csp(); + + $csp->add('style-src', "'nonce-example'"); + + $this->assertEquals('example', $csp->getNonce()); + } + + public function testNonceEmpty() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('style-src', "'nonce-'"); + } + + public function testFromString() + { + $csp = Csp::fromString(" script-src 'nonce-example';\n\n\r\nimg-src "); + + $this->assertEquals( + [ + 'script-src' => ["'nonce-example'"], + ], + $csp->getDirectives(), + ); + } +} From 9b843a46f7a6336f2af030af1907139dbb552302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 16:23:25 +0200 Subject: [PATCH 07/23] Use constation for default-src policies --- src/Common/Csp.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index e9cc4bbb7..95a5fae2a 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -10,6 +10,9 @@ */ class Csp { + /** @var string[] The default source directive */ + protected const DEFAULT_SOURCE_DIRECTIVE = ["'self'"]; + /** * @var array> The directives and their values */ @@ -145,11 +148,11 @@ public function getNonce(): ?string * * @param string $directive The directive name * - * @return string[] + * @return string[] The policies of the directive or the default-src directive if none is set explicitly */ public function getDirective(string $directive): array { - return $this->directives[$directive] ?? []; + return $this->directives[$directive] ?? static::DEFAULT_SOURCE_DIRECTIVE; } /** @@ -170,7 +173,7 @@ public function getDirectives(): array */ public function getHeader(): string { - $directiveStrings = ["default-src 'self'"]; + $directiveStrings = ["default-src " . implode(' ', static::DEFAULT_SOURCE_DIRECTIVE)]; foreach ($this->directives as $directive => $values) { $directiveStrings[] = sprintf('%s %s', $directive, implode(' ', $values)); } From ac9545566fa0e2fc7225cc6ca2c75e2a161f3dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 16:24:24 +0200 Subject: [PATCH 08/23] Add policy validation and url evaluation --- src/Common/Csp.php | 147 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 95a5fae2a..f58d4f329 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -2,6 +2,7 @@ namespace ipl\Web\Common; +use GuzzleHttp\Psr7\ServerRequest; use InvalidArgumentException; /** @@ -112,6 +113,8 @@ public function add(string $directive, string|array $value): static return $this; } + $this->validatePolicy($value); + $this->directives[$directive][] = $value; if ( @@ -121,7 +124,7 @@ public function add(string $directive, string|array $value): static ) { $nonce = substr($value, 7, -1); if (empty($nonce)) { - throw new InvalidArgumentException("Nonce cannot must have a value."); + throw new InvalidArgumentException("Nonce must have a value."); } $this->nonce = $nonce; @@ -189,4 +192,146 @@ public function isEmpty(): bool { return empty($this->directives); } + + /** + * Validate a policy. Throws an exception if the policy is invalid. + * + * @param string $policy The policy to validate + * + * @return void + */ + protected function validatePolicy(string $policy): void + { + if ($policy === '*') { + return; + } + + if (str_contains($policy, ' ')) { + throw new InvalidArgumentException("Policy must not contain spaces. policy: $policy"); + } + + if ( + (str_starts_with($policy, "'") && ! str_ends_with($policy, "'")) + || ! str_starts_with($policy, "'") && str_ends_with($policy, "'") + ) { + throw new InvalidArgumentException( + "Quoted policy must be fully surrounded by single quotes. policy: $policy", + ); + } + + if (str_starts_with($policy, "'") && str_ends_with($policy, "'")) { + return; + } + + // scheme and scheme://* + if (preg_match('/^[a-z]+:(\/\/\*)?$/', $policy)) { + return; + } + + // Reporting names + if (preg_match('/^[a-zA-Z0-9_-]+$/', $policy)) { + return; + } + + $parsedUrl = parse_url($policy); + if ($parsedUrl === false) { + throw new InvalidArgumentException("Policy must be a valid URL. policy: $policy"); + } + + if (! isset($parsedUrl['host'])) { + throw new InvalidArgumentException("Policy URL must specify a host. policy: $policy"); + } + + if (! isset($parsedUrl['scheme'])) { + throw new InvalidArgumentException("Policy URL must specify a scheme. policy: $policy"); + } + + if (str_starts_with($parsedUrl['host'], '*')) { + if (! str_starts_with($parsedUrl['host'], '*.')) { + throw new InvalidArgumentException("Wildcard host must be a full subdomain. policy: $policy"); + } + } else { + if (str_contains($parsedUrl['host'], '*')) { + throw new InvalidArgumentException("Wildcards can only be used at the start of the host. policy: $policy"); + } + } + } + + /** + * Evaluates a URL against a CSP directive. + * Returns true if the URL is allowed by the directive. + * This method only checks the URL's scheme and host and path. Nonce and hash are not checked because they can't be + * represented inside a URL. + * + * @param string $directive The CSP directive to evaluate the URL against + * @param string $url The URL to evaluate + * + * @return bool + */ + public function evaluateUrl(string $directive, string $url): bool + { + $policies = $this->getDirective($directive); + + // 'none' is only supported if it is the only policy. + // If it is combined with other values, browsers ignore 'none' + if (count($policies) === 1 && $policies[0] === "'none'") { + return false; + } + + if (in_array('*', $policies)) { + return true; + } + + $parsedUrl = parse_url($url); + $scheme = $parsedUrl['scheme'] ?? null; + if (in_array("'self'", $policies)) { + $requestUri = ServerRequest::getUriFromGlobals(); + if ( + ($scheme === null || $requestUri->getScheme() === $scheme) + && $requestUri->getHost() === $parsedUrl['host'] + ) { + return true; + } + } + + foreach ($policies as $policy) { + if (str_starts_with($policy, "'") && str_ends_with($policy, "'")) { + continue; + } + + if ($scheme !== null && ($policy === $scheme . ':' || $policy === $scheme . '://')) { + return true; + } + + $parsedPolicyUrl = parse_url($policy); + if (! isset($parsedPolicyUrl['scheme']) || ! isset($parsedPolicyUrl['host'])) { + continue; + } + + $parsedPolicyPath = $parsedPolicyUrl['path'] ?? null; + $pathIsDirectory = $parsedPolicyPath !== null && str_ends_with($parsedPolicyPath, '/'); + $parsedPath = $parsedUrl['path'] ?? null; + if ( + ($scheme === null || $parsedPolicyUrl['scheme'] === $scheme) + && $parsedPolicyUrl['host'] === $parsedUrl['host'] + && ($parsedPolicyPath === null || ( + $pathIsDirectory && $parsedPath !== null && str_starts_with($parsedPath, $parsedPolicyPath) + || $parsedPath === $parsedPolicyPath + )) + ) { + return true; + } + + // Note: https://*.example.com means https://example.com and https://sub.example.com + if ( + ($scheme === null || $parsedPolicyUrl['scheme'] === $scheme) + && str_starts_with($parsedPolicyUrl['host'], '*') + && (str_ends_with($parsedUrl['host'], substr($parsedPolicyUrl['host'], 2))) + ) { + return true; + } + } + + return false; + } } From 9f4dd3aec69d7e43a0474dcafedfac82e18f1e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 09:44:14 +0200 Subject: [PATCH 09/23] Remove unnecessary space check policies can never have a space since policies itself are space delimited. --- src/Common/Csp.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index f58d4f329..2f8f9c1e7 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -206,10 +206,6 @@ protected function validatePolicy(string $policy): void return; } - if (str_contains($policy, ' ')) { - throw new InvalidArgumentException("Policy must not contain spaces. policy: $policy"); - } - if ( (str_starts_with($policy, "'") && ! str_ends_with($policy, "'")) || ! str_starts_with($policy, "'") && str_ends_with($policy, "'") From c8934de5937c656dd0d408f6c5ecbdfd7bb0ea38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 09:44:27 +0200 Subject: [PATCH 10/23] Add more tests --- tests/Common/CspTest.php | 190 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index 91aaca4a4..648459aa9 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -84,6 +84,13 @@ public function testAddDefaultSource() $csp->add('default-src', 'https://example.com'); } + public function testFallbackToDefault() + { + $csp = new Csp(); + + $this->assertEquals(["'self'"], $csp->getDirective('script-src')); + } + public function testAddDirectiveNameCapitals() { $this->expectException(InvalidArgumentException::class); @@ -102,6 +109,94 @@ public function testAddDirectiveNameSpecialCharacters() $csp->add('default-src:', 'https://example.com'); } + public function testAddWildcardEverything() + { + $csp = new Csp(); + $csp->add('script-src', '*'); + + $this->assertEquals(['*'], $csp->getDirective('script-src')); + } + + public function testAddWildcard() + { + $csp = new Csp(); + $csp->add('script-src', 'https://*.example.com'); + $csp->add('script-src', 'https://*.int.example.com'); + + $this->assertEquals( + ['https://*.example.com', 'https://*.int.example.com'], + $csp->getDirective('script-src'), + ); + } + + public function testAddMissingEndQuote() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', "'self"); + } + + public function testAddMissingStartQuote() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', "self'"); + } + + public function testAddScheme() + { + $csp = new Csp(); + $csp->add('script-src', 'https://*'); + $csp->add('script-src', 'http:'); + + $this->assertEquals(['https://*', 'http:'], $csp->getDirective('script-src')); + } + + public function testAddReportingName() + { + $csp = new Csp(); + + $csp->add('report-to', 'reporting-endpoint'); + + $this->assertEquals(['reporting-endpoint'], $csp->getDirective('report-to')); + } + + /** + * @dataProvider providerInvalidWildcards + */ + public function testAddInvalidWildcard(string $policy) + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', $policy); + } + + public function providerInvalidWildcards(): array { + return [ + ['https://example.com*'], + ['https://a*.example.com'], + ['https://*c.example.com'], + ['https://a*c.example.com'], + ['https://a*.int.example.com'], + ['https://*c.int.example.com'], + ['https://a*c.int.example.com'], + ['https://int.a*.example.com'], + ['https://int.*c.example.com'], + ['https://int.a*c.example.com'], + ['https://example.*'], + ['https://example.*om'], + ['https://example.c*m'], + ['https://example.co*'], + ['https://exa*ple.com'], + ]; + } + public function testGetDirectives() { $csp = new Csp(); @@ -159,4 +254,99 @@ public function testFromString() $csp->getDirectives(), ); } + + public function testEvaluateWildcardEverything() + { + $csp = new Csp(); + $csp->add('script-src', '*'); + + $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); + } + + public function testEvaluateNone() + { + $csp = new Csp(); + $csp->add('script-src', "'none'"); + + $this->assertFalse($csp->evaluateUrl('script-src', 'https://example.com')); + $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); + $this->assertFalse($csp->evaluateUrl('script-src', 'test')); + } + + public function testEvaluateNoneWithMultiplePolicies() + { + $csp = new Csp(); + $csp->add('script-src', "'none'"); + $csp->add('script-src', 'https://example.com'); + + $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); + $this->assertFalse($csp->evaluateUrl('script-src', 'https://foo.com')); + } + + public function testEvaluateSelf() + { + $csp = new Csp(); + $csp->add('script-src', "'self'"); + + // Note: This works because the request url for unit tests is always http://localhost + $this->assertTrue($csp->evaluateUrl('script-src', 'http://localhost')); + } + + public function testEvaluateSchema() + { + $csp = new Csp(); + $csp->add('script-src', 'https:'); + + $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); + $this->assertTrue($csp->evaluateUrl('script-src', 'https://int.example.com')); + $this->assertTrue($csp->evaluateUrl('script-src', 'https://icinga.com')); + $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); + } + + public function testEvaluateWildcardSchema() + { + $csp = new Csp(); + $csp->add('script-src', 'https://*'); + + $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); + $this->assertTrue($csp->evaluateUrl('script-src', 'https://int.example.com')); + $this->assertTrue($csp->evaluateUrl('script-src', 'https://icinga.com')); + $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); + } + + public function testEvaluatePathDirectory() + { + $csp = new Csp(); + $csp->add('frame-src', 'https://example.com/blog/'); + + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html')); + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/year/month/date/some-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com/blog/')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com/blog')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com')); + } + + public function testEvaluatePathFile() + { + $csp = new Csp(); + $csp->add('frame-src', 'https://example.com/blog/some-article.html'); + + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://blog.example.com/blog/some-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/blog/another-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/feed/some-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/feed/some-article')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://icinga.com/blog/some-article.html')); + } + + public function testEvaluateWildcardHost() + { + $csp = new Csp(); + $csp->add('frame-src', 'https://*.example.com'); + + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com')); + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://cdn.example.com')); + $this->assertTrue($csp->evaluateUrl('frame-src', 'https://monitoring.int.example.com')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://icinga.com')); + } } From 0fd66a297a5a2351165f59a56dd1681586f0c28b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 09:53:07 +0200 Subject: [PATCH 11/23] Code style changes --- src/Common/Csp.php | 4 +++- tests/Common/CspTest.php | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 2f8f9c1e7..756482852 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -248,7 +248,9 @@ protected function validatePolicy(string $policy): void } } else { if (str_contains($parsedUrl['host'], '*')) { - throw new InvalidArgumentException("Wildcards can only be used at the start of the host. policy: $policy"); + throw new InvalidArgumentException( + "Wildcards can only be used at the start of the host. policy: $policy", + ); } } } diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index 648459aa9..2f268f161 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -177,7 +177,8 @@ public function testAddInvalidWildcard(string $policy) $csp->add('script-src', $policy); } - public function providerInvalidWildcards(): array { + public function providerInvalidWildcards(): array + { return [ ['https://example.com*'], ['https://a*.example.com'], From 1224df7609a772636cb8fba98b7947f319745d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 09:54:41 +0200 Subject: [PATCH 12/23] Change dataprovider to use attribute --- tests/Common/CspTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index 2f268f161..f29a5eb1f 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -2,6 +2,7 @@ namespace ipl\Tests\Web\Common; +use PHPUnit\Framework\Attributes\DataProvider; use InvalidArgumentException; use ipl\Tests\Web\TestCase; use ipl\Web\Common\Csp; @@ -165,9 +166,7 @@ public function testAddReportingName() $this->assertEquals(['reporting-endpoint'], $csp->getDirective('report-to')); } - /** - * @dataProvider providerInvalidWildcards - */ + #[DataProvider('providerInvalidWildcards')] public function testAddInvalidWildcard(string $policy) { $this->expectException(InvalidArgumentException::class); From e9da226087999833097550730b7024defe4f8e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 09:59:03 +0200 Subject: [PATCH 13/23] fixup! make data provider static --- tests/Common/CspTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index f29a5eb1f..bffee4f5c 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -176,7 +176,7 @@ public function testAddInvalidWildcard(string $policy) $csp->add('script-src', $policy); } - public function providerInvalidWildcards(): array + public static function providerInvalidWildcards(): array { return [ ['https://example.com*'], From 2ae7a6213058f8eb30a04729a08f793a8ca6cfda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 10:12:55 +0200 Subject: [PATCH 14/23] fixup! phpstan changes --- src/Common/Csp.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 756482852..026f62651 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -281,6 +281,11 @@ public function evaluateUrl(string $directive, string $url): bool } $parsedUrl = parse_url($url); + + if (! isset($parsedUrl['host'])) { + throw new InvalidArgumentException("URL must specify a host. url: $url"); + } + $scheme = $parsedUrl['scheme'] ?? null; if (in_array("'self'", $policies)) { $requestUri = ServerRequest::getUriFromGlobals(); From c7e9650a967e904f8790742f4912c543bd8d1d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 7 Apr 2026 10:28:50 +0200 Subject: [PATCH 15/23] fixup! add check to test if path can be extended --- tests/Common/CspTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index bffee4f5c..6b5bd2f6c 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -332,6 +332,7 @@ public function testEvaluatePathFile() $csp->add('frame-src', 'https://example.com/blog/some-article.html'); $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html')); + $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html/style.css')); $this->assertFalse($csp->evaluateUrl('frame-src', 'https://blog.example.com/blog/some-article.html')); $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/blog/another-article.html')); $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/feed/some-article.html')); From 70836522b6c6c3abe43d7a35846e8e31f9155d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 9 Apr 2026 09:34:19 +0200 Subject: [PATCH 16/23] code review suggestions --- src/Common/Csp.php | 30 +++++++++++++++--------------- tests/Common/CspTest.php | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 026f62651..fb5fe4d96 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -66,7 +66,7 @@ public static function fromString(string $header): static } $parts = explode(' ', $directive, 2); if (count($parts) < 2) { - continue; + throw new InvalidArgumentException("Directives must contain the directive name and at least one policy."); } $result->add($parts[0], $parts[1]); } @@ -211,7 +211,7 @@ protected function validatePolicy(string $policy): void || ! str_starts_with($policy, "'") && str_ends_with($policy, "'") ) { throw new InvalidArgumentException( - "Quoted policy must be fully surrounded by single quotes. policy: $policy", + "Quoted policy must be fully surrounded by single quotes. Policy: $policy", ); } @@ -219,7 +219,7 @@ protected function validatePolicy(string $policy): void return; } - // scheme and scheme://* + // scheme: and scheme://* if (preg_match('/^[a-z]+:(\/\/\*)?$/', $policy)) { return; } @@ -231,25 +231,25 @@ protected function validatePolicy(string $policy): void $parsedUrl = parse_url($policy); if ($parsedUrl === false) { - throw new InvalidArgumentException("Policy must be a valid URL. policy: $policy"); + throw new InvalidArgumentException("Policy must be a valid URL. Policy: $policy"); } if (! isset($parsedUrl['host'])) { - throw new InvalidArgumentException("Policy URL must specify a host. policy: $policy"); + throw new InvalidArgumentException("Policy URL must specify a host. Policy: $policy"); } if (! isset($parsedUrl['scheme'])) { - throw new InvalidArgumentException("Policy URL must specify a scheme. policy: $policy"); + throw new InvalidArgumentException("Policy URL must specify a scheme. Policy: $policy"); } if (str_starts_with($parsedUrl['host'], '*')) { if (! str_starts_with($parsedUrl['host'], '*.')) { - throw new InvalidArgumentException("Wildcard host must be a full subdomain. policy: $policy"); + throw new InvalidArgumentException("Wildcard host must be a full subdomain. Policy: $policy"); } } else { if (str_contains($parsedUrl['host'], '*')) { throw new InvalidArgumentException( - "Wildcards can only be used at the start of the host. policy: $policy", + "Wildcards can only be used at the start of the host. Policy: $policy", ); } } @@ -268,6 +268,12 @@ protected function validatePolicy(string $policy): void */ public function evaluateUrl(string $directive, string $url): bool { + $parsedUrl = parse_url($url); + + if (! isset($parsedUrl['host'])) { + throw new InvalidArgumentException("URL must specify a host. URL: $url"); + } + $policies = $this->getDirective($directive); // 'none' is only supported if it is the only policy. @@ -280,12 +286,6 @@ public function evaluateUrl(string $directive, string $url): bool return true; } - $parsedUrl = parse_url($url); - - if (! isset($parsedUrl['host'])) { - throw new InvalidArgumentException("URL must specify a host. url: $url"); - } - $scheme = $parsedUrl['scheme'] ?? null; if (in_array("'self'", $policies)) { $requestUri = ServerRequest::getUriFromGlobals(); @@ -302,7 +302,7 @@ public function evaluateUrl(string $directive, string $url): bool continue; } - if ($scheme !== null && ($policy === $scheme . ':' || $policy === $scheme . '://')) { + if ($scheme !== null && ($policy === $scheme . ':' || $policy === $scheme . '://*')) { return true; } diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index 6b5bd2f6c..6905fb048 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -245,11 +245,12 @@ public function testNonceEmpty() public function testFromString() { - $csp = Csp::fromString(" script-src 'nonce-example';\n\n\r\nimg-src "); + $csp = Csp::fromString(" script-src 'nonce-example';\n\n\r\nimg-src 'self' https://example.com"); $this->assertEquals( [ 'script-src' => ["'nonce-example'"], + 'img-src' => ["'self'", 'https://example.com'], ], $csp->getDirectives(), ); @@ -270,7 +271,6 @@ public function testEvaluateNone() $this->assertFalse($csp->evaluateUrl('script-src', 'https://example.com')); $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); - $this->assertFalse($csp->evaluateUrl('script-src', 'test')); } public function testEvaluateNoneWithMultiplePolicies() From 18f914f65ff24eae7bd0fb48cac580396e2cd3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 9 Apr 2026 09:51:24 +0200 Subject: [PATCH 17/23] rename policy to expression to better match the spec --- src/Common/Csp.php | 94 +++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index fb5fe4d96..a66d39fd1 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -7,12 +7,12 @@ /** * Represents a Content Security Policy (CSP) header. - * Methods are additive, and duplicate policies are ignored. + * Methods are additive, and duplicate expressions are ignored. */ class Csp { - /** @var string[] The default source directive */ - protected const DEFAULT_SOURCE_DIRECTIVE = ["'self'"]; + /** @var string[] The expressions for the default-src directive */ + protected const DEFAULT_SOURCE_EXPRESSIONS = ["'self'"]; /** * @var array> The directives and their values @@ -66,7 +66,9 @@ public static function fromString(string $header): static } $parts = explode(' ', $directive, 2); if (count($parts) < 2) { - throw new InvalidArgumentException("Directives must contain the directive name and at least one policy."); + throw new InvalidArgumentException( + "Directives must contain the directive name and at least one expression." + ); } $result->add($parts[0], $parts[1]); } @@ -75,10 +77,10 @@ public static function fromString(string $header): static } /** - * Add a directive with a policy or a list of policies to the CSP + * Add a directive with a expression or a list of expressions to the CSP * * @param string $directive The directive name - * @param string|string[] $value The policy or list of policies to add + * @param string|string[] $value The expression or list of expressions to add * * @return $this */ @@ -113,7 +115,7 @@ public function add(string $directive, string|array $value): static return $this; } - $this->validatePolicy($value); + $this->validateExpression($value); $this->directives[$directive][] = $value; @@ -151,11 +153,11 @@ public function getNonce(): ?string * * @param string $directive The directive name * - * @return string[] The policies of the directive or the default-src directive if none is set explicitly + * @return string[] The expressions of the directive or the default-src directive if none is set explicitly */ public function getDirective(string $directive): array { - return $this->directives[$directive] ?? static::DEFAULT_SOURCE_DIRECTIVE; + return $this->directives[$directive] ?? static::DEFAULT_SOURCE_EXPRESSIONS; } /** @@ -176,7 +178,7 @@ public function getDirectives(): array */ public function getHeader(): string { - $directiveStrings = ["default-src " . implode(' ', static::DEFAULT_SOURCE_DIRECTIVE)]; + $directiveStrings = ["default-src " . implode(' ', static::DEFAULT_SOURCE_EXPRESSIONS)]; foreach ($this->directives as $directive => $values) { $directiveStrings[] = sprintf('%s %s', $directive, implode(' ', $values)); } @@ -194,62 +196,62 @@ public function isEmpty(): bool } /** - * Validate a policy. Throws an exception if the policy is invalid. + * Validate an expression. Throws an exception if the expression is invalid. * - * @param string $policy The policy to validate + * @param string $expression The expression to validate * * @return void */ - protected function validatePolicy(string $policy): void + protected function validateExpression(string $expression): void { - if ($policy === '*') { + if ($expression === '*') { return; } if ( - (str_starts_with($policy, "'") && ! str_ends_with($policy, "'")) - || ! str_starts_with($policy, "'") && str_ends_with($policy, "'") + (str_starts_with($expression, "'") && ! str_ends_with($expression, "'")) + || ! str_starts_with($expression, "'") && str_ends_with($expression, "'") ) { throw new InvalidArgumentException( - "Quoted policy must be fully surrounded by single quotes. Policy: $policy", + "Quoted expression must be fully surrounded by single quotes. Expression: $expression", ); } - if (str_starts_with($policy, "'") && str_ends_with($policy, "'")) { + if (str_starts_with($expression, "'") && str_ends_with($expression, "'")) { return; } // scheme: and scheme://* - if (preg_match('/^[a-z]+:(\/\/\*)?$/', $policy)) { + if (preg_match('/^[a-z]+:(\/\/\*)?$/', $expression)) { return; } // Reporting names - if (preg_match('/^[a-zA-Z0-9_-]+$/', $policy)) { + if (preg_match('/^[a-zA-Z0-9_-]+$/', $expression)) { return; } - $parsedUrl = parse_url($policy); + $parsedUrl = parse_url($expression); if ($parsedUrl === false) { - throw new InvalidArgumentException("Policy must be a valid URL. Policy: $policy"); + throw new InvalidArgumentException("Expression must be a valid URL. Expression: $expression"); } if (! isset($parsedUrl['host'])) { - throw new InvalidArgumentException("Policy URL must specify a host. Policy: $policy"); + throw new InvalidArgumentException("Expression URL must specify a host. Expression: $expression"); } if (! isset($parsedUrl['scheme'])) { - throw new InvalidArgumentException("Policy URL must specify a scheme. Policy: $policy"); + throw new InvalidArgumentException("Expression URL must specify a scheme. Expression: $expression"); } if (str_starts_with($parsedUrl['host'], '*')) { if (! str_starts_with($parsedUrl['host'], '*.')) { - throw new InvalidArgumentException("Wildcard host must be a full subdomain. Policy: $policy"); + throw new InvalidArgumentException("Wildcard host must be a full subdomain. Expression: $expression"); } } else { if (str_contains($parsedUrl['host'], '*')) { throw new InvalidArgumentException( - "Wildcards can only be used at the start of the host. Policy: $policy", + "Wildcards can only be used at the start of the host. Expression: $expression", ); } } @@ -274,20 +276,20 @@ public function evaluateUrl(string $directive, string $url): bool throw new InvalidArgumentException("URL must specify a host. URL: $url"); } - $policies = $this->getDirective($directive); + $expressions = $this->getDirective($directive); - // 'none' is only supported if it is the only policy. + // 'none' is only supported if it is the only expression. // If it is combined with other values, browsers ignore 'none' - if (count($policies) === 1 && $policies[0] === "'none'") { + if (count($expressions) === 1 && $expressions[0] === "'none'") { return false; } - if (in_array('*', $policies)) { + if (in_array('*', $expressions)) { return true; } $scheme = $parsedUrl['scheme'] ?? null; - if (in_array("'self'", $policies)) { + if (in_array("'self'", $expressions)) { $requestUri = ServerRequest::getUriFromGlobals(); if ( ($scheme === null || $requestUri->getScheme() === $scheme) @@ -297,29 +299,29 @@ public function evaluateUrl(string $directive, string $url): bool } } - foreach ($policies as $policy) { - if (str_starts_with($policy, "'") && str_ends_with($policy, "'")) { + foreach ($expressions as $expression) { + if (str_starts_with($expression, "'") && str_ends_with($expression, "'")) { continue; } - if ($scheme !== null && ($policy === $scheme . ':' || $policy === $scheme . '://*')) { + if ($scheme !== null && ($expression === $scheme . ':' || $expression === $scheme . '://*')) { return true; } - $parsedPolicyUrl = parse_url($policy); - if (! isset($parsedPolicyUrl['scheme']) || ! isset($parsedPolicyUrl['host'])) { + $parsedExpressionUrl = parse_url($expression); + if (! isset($parsedExpressionUrl['scheme']) || ! isset($parsedExpressionUrl['host'])) { continue; } - $parsedPolicyPath = $parsedPolicyUrl['path'] ?? null; - $pathIsDirectory = $parsedPolicyPath !== null && str_ends_with($parsedPolicyPath, '/'); + $parsedExpressionPath = $parsedExpressionUrl['path'] ?? null; + $pathIsDirectory = $parsedExpressionPath !== null && str_ends_with($parsedExpressionPath, '/'); $parsedPath = $parsedUrl['path'] ?? null; if ( - ($scheme === null || $parsedPolicyUrl['scheme'] === $scheme) - && $parsedPolicyUrl['host'] === $parsedUrl['host'] - && ($parsedPolicyPath === null || ( - $pathIsDirectory && $parsedPath !== null && str_starts_with($parsedPath, $parsedPolicyPath) - || $parsedPath === $parsedPolicyPath + ($scheme === null || $parsedExpressionUrl['scheme'] === $scheme) + && $parsedExpressionUrl['host'] === $parsedUrl['host'] + && ($parsedExpressionPath === null || ( + $pathIsDirectory && $parsedPath !== null && str_starts_with($parsedPath, $parsedExpressionPath) + || $parsedPath === $parsedExpressionPath )) ) { return true; @@ -327,9 +329,9 @@ public function evaluateUrl(string $directive, string $url): bool // Note: https://*.example.com means https://example.com and https://sub.example.com if ( - ($scheme === null || $parsedPolicyUrl['scheme'] === $scheme) - && str_starts_with($parsedPolicyUrl['host'], '*') - && (str_ends_with($parsedUrl['host'], substr($parsedPolicyUrl['host'], 2))) + ($scheme === null || $parsedExpressionUrl['scheme'] === $scheme) + && str_starts_with($parsedExpressionUrl['host'], '*') + && (str_ends_with($parsedUrl['host'], substr($parsedExpressionUrl['host'], 2))) ) { return true; } From e56c1afe2a032517516843ec5769d8f8c43e25d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 9 Apr 2026 10:55:31 +0200 Subject: [PATCH 18/23] Allow certain directives to be empty --- src/Common/Csp.php | 82 +++++++++++++++++++++++++++------- tests/Common/CspTest.php | 95 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 16 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index a66d39fd1..a191a536e 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -14,6 +14,17 @@ class Csp /** @var string[] The expressions for the default-src directive */ protected const DEFAULT_SOURCE_EXPRESSIONS = ["'self'"]; + /** @var string[] The directives that can be empty */ + protected const POSSIBLE_EMPTY_DIRECTIVES = [ + 'sandbox', + ]; + + /** @var string[] The directives that must be empty */ + protected const MANDATORY_EMPTY_DIRECTIVES = [ + 'block-all-mixed-content', + 'upgrade-insecure-requests', + ]; + /** * @var array> The directives and their values */ @@ -46,6 +57,19 @@ public static function merge(Csp ...$csps): static return $result; } + /** + * Only a subset of directives can be empty. Allowing them to be empty does not mean they cannot have a value, + * only that it can be omitted. + * @param string $directive The directive name + * + * @return bool + */ + protected function canDirectiveBeEmpty(string $directive): bool + { + return in_array($directive, static::POSSIBLE_EMPTY_DIRECTIVES, true) + || in_array($directive, static::MANDATORY_EMPTY_DIRECTIVES, true); + } + /** * Create a new CSP from a string * @@ -65,12 +89,17 @@ public static function fromString(string $header): static continue; } $parts = explode(' ', $directive, 2); - if (count($parts) < 2) { - throw new InvalidArgumentException( - "Directives must contain the directive name and at least one expression." - ); + $name = $parts[0]; + if (count($parts) == 1) { + if (! $result->canDirectiveBeEmpty($name)) { + throw new InvalidArgumentException( + "Directives must contain the directive name and at least one expression. Directive: $directive" + ); + } + $result->add($name, null); + } else { + $result->add($parts[0], $parts[1]); } - $result->add($parts[0], $parts[1]); } return $result; @@ -80,11 +109,11 @@ public static function fromString(string $header): static * Add a directive with a expression or a list of expressions to the CSP * * @param string $directive The directive name - * @param string|string[] $value The expression or list of expressions to add + * @param string|string[]|null $value The expression or list of expressions to add * * @return $this */ - public function add(string $directive, string|array $value): static + public function add(string $directive, string|array|null $value): static { if ($directive === "default-src") { throw new InvalidArgumentException("Changing default-src is forbidden."); @@ -96,7 +125,26 @@ public function add(string $directive, string|array $value): static ); } - if (is_string($value)) { + if ($value !== null && in_array($directive, static::MANDATORY_EMPTY_DIRECTIVES, true)) { + throw new InvalidArgumentException( + "Directive $directive can not have a value." + ); + } + + if ($value == null) { + if (! $this->canDirectiveBeEmpty($directive)) { + throw new InvalidArgumentException( + "Directive $directive can not be empty." + ); + } + if (! isset($this->directives[$directive])) { + $this->directives[$directive] = []; + } + + if (in_array($value, $this->directives[$directive])) { + return $this; + } + } else if (is_string($value)) { $value = trim($value); if (str_contains($value, ' ')) { @@ -107,15 +155,15 @@ public function add(string $directive, string|array $value): static return $this; } - if (! isset($this->directives[$directive])) { - $this->directives[$directive] = []; - } + $this->validateExpression($value); - if (in_array($value, $this->directives[$directive])) { + if (in_array($value, $this->directives[$directive] ?? [])) { return $this; } - $this->validateExpression($value); + if (! isset($this->directives[$directive])) { + $this->directives[$directive] = []; + } $this->directives[$directive][] = $value; @@ -179,8 +227,8 @@ public function getDirectives(): array public function getHeader(): string { $directiveStrings = ["default-src " . implode(' ', static::DEFAULT_SOURCE_EXPRESSIONS)]; - foreach ($this->directives as $directive => $values) { - $directiveStrings[] = sprintf('%s %s', $directive, implode(' ', $values)); + foreach ($this->directives as $directive => $expressions) { + $directiveStrings[] = implode(' ', array_merge([$directive], $expressions)); } return implode('; ', $directiveStrings); } @@ -204,6 +252,10 @@ public function isEmpty(): bool */ protected function validateExpression(string $expression): void { + if ($expression === '') { + throw new InvalidArgumentException("Expression must not be empty."); + } + if ($expression === '*') { return; } diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index 6905fb048..d618cb666 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -32,11 +32,47 @@ public function testAddString() public function testAddStringEmpty() { + $this->expectException(InvalidArgumentException::class); + $csp = new Csp(); $csp->add('script-src', ''); + } - $this->assertTrue($csp->isEmpty()); + public function testAddNull() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', null); + } + + public function testAddNullOnAllowedEmptyDirective() + { + $csp = new Csp(); + + $csp->add('sandbox', null); + + $this->assertEquals([], $csp->getDirective('sandbox')); + } + + public function testAddNullOnMandatoryEmptyDirective() + { + $csp = new Csp(); + + $csp->add('block-all-mixed-content', null); + + $this->assertEquals([], $csp->getDirective('block-all-mixed-content')); + } + + public function testAddStringOnMandatoryEmptyDirective() + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('block-all-mixed-content', 'example'); } public function testAddStringTrim() @@ -226,6 +262,17 @@ public function testGetHeader() ); } + public function testGetHeaderWithNullableDirectives() + { + $csp = new Csp(); + $csp->add('sandbox', null); + + $this->assertEquals( + 'default-src \'self\'; sandbox', + $csp->getHeader(), + ); + } + public function testNonce() { $csp = new Csp(); @@ -256,6 +303,52 @@ public function testFromString() ); } + public function testFromStringOptionalEmpty() + { + $csp = Csp::fromString("script-src 'nonce-example';\nsandbox;"); + + $this->assertEquals( + [ + 'script-src' => ["'nonce-example'"], + 'sandbox' => [], + ], + $csp->getDirectives(), + ); + } + + public function testFromStringOptionalEmptyWithValue() + { + $csp = Csp::fromString("script-src 'nonce-example';\nsandbox allow-scripts allow-forms;"); + + $this->assertEquals( + [ + 'script-src' => ["'nonce-example'"], + 'sandbox' => ['allow-scripts', 'allow-forms'], + ], + $csp->getDirectives(), + ); + } + + public function testFromStringMandatoryEmpty() + { + $csp = Csp::fromString("script-src 'nonce-example';\nblock-all-mixed-content;"); + + $this->assertEquals( + [ + 'script-src' => ["'nonce-example'"], + 'block-all-mixed-content' => [], + ], + $csp->getDirectives(), + ); + } + + public function testFromStringMandatoryEmptyWithValue() + { + $this->expectException(InvalidArgumentException::class); + + Csp::fromString("script-src 'nonce-example';\nblock-all-mixed-content foo;"); + } + public function testEvaluateWildcardEverything() { $csp = new Csp(); From 1e383336cca2696baf3a5bd04592e0c6a5ec5918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 9 Apr 2026 11:18:24 +0200 Subject: [PATCH 19/23] fixup! phpcs --- src/Common/Csp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index a191a536e..cbea9779a 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -144,7 +144,7 @@ public function add(string $directive, string|array|null $value): static if (in_array($value, $this->directives[$directive])) { return $this; } - } else if (is_string($value)) { + } elseif (is_string($value)) { $value = trim($value); if (str_contains($value, ' ')) { From 022d1ac9758575ef5963b29dc6ddd77fdad8f83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 16 Apr 2026 13:54:04 +0200 Subject: [PATCH 20/23] Remove `evaluateUrl` --- src/Common/Csp.php | 83 ----------------------------------- tests/Common/CspTest.php | 95 ---------------------------------------- 2 files changed, 178 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index cbea9779a..28af28afd 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -308,87 +308,4 @@ protected function validateExpression(string $expression): void } } } - - /** - * Evaluates a URL against a CSP directive. - * Returns true if the URL is allowed by the directive. - * This method only checks the URL's scheme and host and path. Nonce and hash are not checked because they can't be - * represented inside a URL. - * - * @param string $directive The CSP directive to evaluate the URL against - * @param string $url The URL to evaluate - * - * @return bool - */ - public function evaluateUrl(string $directive, string $url): bool - { - $parsedUrl = parse_url($url); - - if (! isset($parsedUrl['host'])) { - throw new InvalidArgumentException("URL must specify a host. URL: $url"); - } - - $expressions = $this->getDirective($directive); - - // 'none' is only supported if it is the only expression. - // If it is combined with other values, browsers ignore 'none' - if (count($expressions) === 1 && $expressions[0] === "'none'") { - return false; - } - - if (in_array('*', $expressions)) { - return true; - } - - $scheme = $parsedUrl['scheme'] ?? null; - if (in_array("'self'", $expressions)) { - $requestUri = ServerRequest::getUriFromGlobals(); - if ( - ($scheme === null || $requestUri->getScheme() === $scheme) - && $requestUri->getHost() === $parsedUrl['host'] - ) { - return true; - } - } - - foreach ($expressions as $expression) { - if (str_starts_with($expression, "'") && str_ends_with($expression, "'")) { - continue; - } - - if ($scheme !== null && ($expression === $scheme . ':' || $expression === $scheme . '://*')) { - return true; - } - - $parsedExpressionUrl = parse_url($expression); - if (! isset($parsedExpressionUrl['scheme']) || ! isset($parsedExpressionUrl['host'])) { - continue; - } - - $parsedExpressionPath = $parsedExpressionUrl['path'] ?? null; - $pathIsDirectory = $parsedExpressionPath !== null && str_ends_with($parsedExpressionPath, '/'); - $parsedPath = $parsedUrl['path'] ?? null; - if ( - ($scheme === null || $parsedExpressionUrl['scheme'] === $scheme) - && $parsedExpressionUrl['host'] === $parsedUrl['host'] - && ($parsedExpressionPath === null || ( - $pathIsDirectory && $parsedPath !== null && str_starts_with($parsedPath, $parsedExpressionPath) - || $parsedPath === $parsedExpressionPath - )) - ) { - return true; - } - - // Note: https://*.example.com means https://example.com and https://sub.example.com - if ( - ($scheme === null || $parsedExpressionUrl['scheme'] === $scheme) - && str_starts_with($parsedExpressionUrl['host'], '*') - && (str_ends_with($parsedUrl['host'], substr($parsedExpressionUrl['host'], 2))) - ) { - return true; - } - } - - return false; - } } diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php index d618cb666..9c6194d75 100644 --- a/tests/Common/CspTest.php +++ b/tests/Common/CspTest.php @@ -348,99 +348,4 @@ public function testFromStringMandatoryEmptyWithValue() Csp::fromString("script-src 'nonce-example';\nblock-all-mixed-content foo;"); } - - public function testEvaluateWildcardEverything() - { - $csp = new Csp(); - $csp->add('script-src', '*'); - - $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); - } - - public function testEvaluateNone() - { - $csp = new Csp(); - $csp->add('script-src', "'none'"); - - $this->assertFalse($csp->evaluateUrl('script-src', 'https://example.com')); - $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); - } - - public function testEvaluateNoneWithMultiplePolicies() - { - $csp = new Csp(); - $csp->add('script-src', "'none'"); - $csp->add('script-src', 'https://example.com'); - - $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); - $this->assertFalse($csp->evaluateUrl('script-src', 'https://foo.com')); - } - - public function testEvaluateSelf() - { - $csp = new Csp(); - $csp->add('script-src', "'self'"); - - // Note: This works because the request url for unit tests is always http://localhost - $this->assertTrue($csp->evaluateUrl('script-src', 'http://localhost')); - } - - public function testEvaluateSchema() - { - $csp = new Csp(); - $csp->add('script-src', 'https:'); - - $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); - $this->assertTrue($csp->evaluateUrl('script-src', 'https://int.example.com')); - $this->assertTrue($csp->evaluateUrl('script-src', 'https://icinga.com')); - $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); - } - - public function testEvaluateWildcardSchema() - { - $csp = new Csp(); - $csp->add('script-src', 'https://*'); - - $this->assertTrue($csp->evaluateUrl('script-src', 'https://example.com')); - $this->assertTrue($csp->evaluateUrl('script-src', 'https://int.example.com')); - $this->assertTrue($csp->evaluateUrl('script-src', 'https://icinga.com')); - $this->assertFalse($csp->evaluateUrl('script-src', 'http://example.com')); - } - - public function testEvaluatePathDirectory() - { - $csp = new Csp(); - $csp->add('frame-src', 'https://example.com/blog/'); - - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html')); - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/year/month/date/some-article.html')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com/blog/')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com/blog')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'http://example.com')); - } - - public function testEvaluatePathFile() - { - $csp = new Csp(); - $csp->add('frame-src', 'https://example.com/blog/some-article.html'); - - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/blog/some-article.html/style.css')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://blog.example.com/blog/some-article.html')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/blog/another-article.html')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/feed/some-article.html')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://example.com/feed/some-article')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://icinga.com/blog/some-article.html')); - } - - public function testEvaluateWildcardHost() - { - $csp = new Csp(); - $csp->add('frame-src', 'https://*.example.com'); - - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://example.com')); - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://cdn.example.com')); - $this->assertTrue($csp->evaluateUrl('frame-src', 'https://monitoring.int.example.com')); - $this->assertFalse($csp->evaluateUrl('frame-src', 'https://icinga.com')); - } } From e1d2234c55ea900ba9a6f1a9f49e2ea3a3783aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 17 Apr 2026 09:02:23 +0200 Subject: [PATCH 21/23] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alexander Aleksandrovič Klimov --- src/Common/Csp.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 28af28afd..4bd611ee8 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -2,7 +2,6 @@ namespace ipl\Web\Common; -use GuzzleHttp\Psr7\ServerRequest; use InvalidArgumentException; /** @@ -79,7 +78,6 @@ protected function canDirectiveBeEmpty(string $directive): bool */ public static function fromString(string $header): static { - $header = trim($header); $header = str_replace("\r\n", ' ', $header); $header = str_replace("\n", ' ', $header); $result = new static(); From 0f22bff843bb7741c2eb7f873d2e274c09484ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 17 Apr 2026 09:03:49 +0200 Subject: [PATCH 22/23] Replace isset with ??= --- src/Common/Csp.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index 4bd611ee8..a8050e7e4 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -135,13 +135,7 @@ public function add(string $directive, string|array|null $value): static "Directive $directive can not be empty." ); } - if (! isset($this->directives[$directive])) { - $this->directives[$directive] = []; - } - - if (in_array($value, $this->directives[$directive])) { - return $this; - } + $this->directives[$directive] ??= []; } elseif (is_string($value)) { $value = trim($value); @@ -159,9 +153,7 @@ public function add(string $directive, string|array|null $value): static return $this; } - if (! isset($this->directives[$directive])) { - $this->directives[$directive] = []; - } + $this->directives[$directive] ??= []; $this->directives[$directive][] = $value; From 60480a8be31a1ab2ff91f5122e906b7e12b0d3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 17 Apr 2026 09:08:49 +0200 Subject: [PATCH 23/23] Remove extra '' check --- src/Common/Csp.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Common/Csp.php b/src/Common/Csp.php index a8050e7e4..48c62f6d0 100644 --- a/src/Common/Csp.php +++ b/src/Common/Csp.php @@ -129,7 +129,7 @@ public function add(string $directive, string|array|null $value): static ); } - if ($value == null) { + if ($value === null) { if (! $this->canDirectiveBeEmpty($directive)) { throw new InvalidArgumentException( "Directive $directive can not be empty." @@ -143,10 +143,6 @@ public function add(string $directive, string|array|null $value): static return $this->add($directive, explode(' ', $value)); } - if (empty($value)) { - return $this; - } - $this->validateExpression($value); if (in_array($value, $this->directives[$directive] ?? [])) {