diff --git a/src/Common/Csp.php b/src/Common/Csp.php new file mode 100644 index 000000000..3380478f8 --- /dev/null +++ b/src/Common/Csp.php @@ -0,0 +1,497 @@ + The characters that can't appear in expressions */ + protected const EXPRESSION_FORBIDDEN_CHARACTERS = [ + ';' => ';', + "\r" => '\r', + "\n" => '\n', + "\t" => '\t', + "\0" => '\0', + ]; + + /** + * @var array A mapping from the directive name and its parent value. + * All fetch directives eventually have the default-src directive as their parent. + */ + protected const FETCH_DIRECTIVES = [ + 'child-src' => 'default-src', + 'connect-src' => 'default-src', + 'default-src' => null, + 'fenced-frame-src' => 'default-src', + 'font-src' => 'default-src', + 'frame-src' => 'default-src', + 'img-src' => 'default-src', + 'manifest-src' => 'default-src', + 'media-src' => 'default-src', + 'object-src' => 'default-src', + 'prefetch-src' => 'default-src', + 'script-src' => 'default-src', + 'script-src-attr' => 'script-src', + 'script-src-elem' => 'script-src', + 'style-src' => 'default-src', + 'style-src-attr' => 'style-src', + 'style-src-elem' => 'style-src', + ]; + + /** @var string[] The directives that must be empty */ + protected const MANDATORY_EMPTY_DIRECTIVES = [ + 'block-all-mixed-content', + 'upgrade-insecure-requests', + ]; + + /** @var string[] The directives that can be empty */ + protected const POSSIBLE_EMPTY_DIRECTIVES = [ + 'sandbox', + ]; + + /** + * @var array> The directives and their values + */ + protected array $directives = []; + + /** + * @var ?string 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 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 + { + $header = str_replace(["\r\n", "\n", "\r"], ' ', $header); + $result = new static(); + foreach (explode(';', $header) as $directive) { + $directive = trim($directive); + if (empty($directive)) { + continue; + } + $parts = preg_split('/\s+/', $directive, 2); + if (count($parts) === 1) { + $name = $parts[0]; + 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); + } + } + + return $result; + } + + /** + * Check if a given directive can be empty + * + * 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); + } + + /** + * Add a directive with an expression or a list of expressions to the CSP + * + * @param string $directive The directive name + * @param string|string[]|null $value The expression or list of expressions to add. + * Note that adding multiple expressions with an array or a space-separated string + * is equivalent to adding each expression individually one by one, the behavior + * is therefore not atomic. + * + * @return $this + * + * @throws InvalidArgumentException If the directive name is invalid, or if the expression is invalid. + */ + public function add(string $directive, string|array|null $value): static + { + if (! preg_match('/^[a-z\-]+$/', $directive)) { + throw new InvalidArgumentException( + "Directive names contain only lowercase letters and '-'. Directive: $directive", + ); + } + + if ($value !== null && in_array($directive, static::MANDATORY_EMPTY_DIRECTIVES, true)) { + throw new InvalidArgumentException( + "Directive $directive can't have a value." + ); + } + + if ($value === null) { + if (! $this->canDirectiveBeEmpty($directive)) { + throw new InvalidArgumentException( + "Directive $directive can't be empty." + ); + } + $this->directives[$directive] ??= []; + } elseif (is_string($value)) { + $value = trim($value, ' '); + + if (str_contains($value, ' ')) { + $values = preg_split('/\s+/', trim($value)); + if ($values === false) { + throw new InvalidArgumentException("Failed to split expression: $value"); + } + + return $this->add($directive, $values); + } + + $this->validateExpression($directive, $value); + + if (in_array($value, $this->directives[$directive] ?? [])) { + return $this; + } + + if ($this->nonce === null && $this->isNonce($value)) { + $this->nonce = substr($value, 7, -1); + } + + $this->directives[$directive] ??= []; + $this->directives[$directive][] = $value; + } else { + if ($value === []) { + return $this->add($directive, null); + } + foreach ($value as $v) { + $this->add($directive, $v); + } + } + + return $this; + } + + /** + * Validate an expression + * + * Throws an exception if the expression is invalid. + * + * @param string $directive The directive name + * @param string $expression The expression to validate + * + * @return void + * + * @throws InvalidArgumentException If the expression is invalid. + */ + protected function validateExpression(string $directive, string $expression): void + { + if ($expression === '') { + throw new InvalidArgumentException("Expression must not be empty."); + } + + if ($expression === '*') { + return; + } + + foreach (static::EXPRESSION_FORBIDDEN_CHARACTERS as $char => $str) { + if (str_contains($expression, $char)) { + throw new InvalidArgumentException( + sprintf("Expression must not contain '%s'.", $str), + ); + } + } + + // Reporting names + if ($directive === 'report-to' && preg_match('/^[a-zA-Z0-9_-]+$/', $expression)) { + return; + } + + if (in_array($expression, static::AVAILABLE_UNQUOTED_KEYWORDS, true)) { + return; + } + + if ( + (str_starts_with($expression, "'") && ! str_ends_with($expression, "'")) + || ! str_starts_with($expression, "'") && str_ends_with($expression, "'") + ) { + throw new InvalidArgumentException( + "Quoted expression must be fully surrounded by single quotes. Expression: $expression", + ); + } + + if (str_starts_with($expression, "'") && str_ends_with($expression, "'")) { + if (str_starts_with($expression, "'nonce-")) { + if (strlen($expression) < 9) { + throw new InvalidArgumentException("Nonce must have a value. Expression: $expression"); + } + + return; + } + + if (str_starts_with($expression, "report-") && $expression !== "'report-sample'") { + foreach (static::AVAILABLE_HASHES as $hash) { + if (str_ends_with($expression, sprintf("-%s'", $hash))) { + return; + } + } + throw new InvalidArgumentException("Unsupported hash type. Expression: $expression"); + } + + if (preg_match('/^\'([a-zA-Z0-9]+)-/', $expression, $matches)) { + $hash = $matches[1]; + if (in_array($hash, static::AVAILABLE_HASHES, true)) { + if (strlen($expression) <= strlen($hash) + 3) { + throw new InvalidArgumentException("Hash must have a value. Expression: $expression"); + } + + return; + } + } + + if (! in_array($expression, static::AVAILABLE_KEYWORDS, true)) { + throw new InvalidArgumentException("Unsupported keyword. Expression: $expression"); + } + + return; + } + + // scheme: and scheme://* + if (preg_match('/^[a-z]+:(\/\/\*)?$/', $expression)) { + return; + } + + preg_match( + '/^(?:(?[a-z]+):\/\/)?(?(?:(?:[a-zA-Z0-9_\-\*]+|\*)\.)' + . '*(?:[\*a-z0-9]+))(?:\:(?-?[0-9]+|\*))?(?(?:\/[a-z0-9\%\-_\.]+)*)/', + $expression, + $parsedUrl, + ); + if ($parsedUrl['host'] === '') { + throw new InvalidArgumentException("Expression URL must specify a host. Expression: $expression"); + } + + if (str_starts_with($parsedUrl['host'], '*')) { + if (! str_starts_with($parsedUrl['host'], '*.')) { + throw new InvalidArgumentException("Wildcard host must be a full subdomain. Expression: $expression"); + } + } elseif (str_contains($parsedUrl['host'], '*')) { + throw new InvalidArgumentException( + "Wildcards can only be used at the start of the host. Expression: $expression", + ); + } + + if ($parsedUrl['port'] !== '') { + if ($parsedUrl['port'] === '*') { + return; + } + + if (! is_numeric($parsedUrl['port'])) { + throw new InvalidArgumentException("Port must be a number. Expression: $expression"); + } + if ($parsedUrl['port'] <= 0 || $parsedUrl['port'] > 0xFFFF) { + throw new InvalidArgumentException("Port must be between 0 and 65535. Expression: $expression"); + } + } + + if ($parsedUrl['path'] !== '') { + if (str_contains($parsedUrl['path'], '*')) { + throw new InvalidArgumentException("Wildcards can't be used in the path. Expression: $expression"); + } + } + } + + /** + * Check if an expression is a nonce + * + * @param string $expression The expression to check + * + * @return bool + */ + protected function isNonce(string $expression): bool + { + return str_starts_with($expression, "'nonce-") && str_ends_with($expression, "'"); + } + + /** + * Get the fully formatted 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 = []; + foreach ($this->directives as $directive => $expressions) { + $directiveStrings[] = implode(' ', array_merge([$directive], $expressions)); + } + return implode('; ', $directiveStrings); + } + + /** + * Get the values of a directive + * + * @param string $directive The directive name. Note that this can be a fetch directive, in which case the values + * will be fetched from its desigated parent directive. + * + * @return string[] + */ + public function getDirective(string $directive): array + { + if (isset($this->directives[$directive])) { + return $this->directives[$directive]; + } elseif (isset(static::FETCH_DIRECTIVES[$directive])) { + return $this->getDirective(static::FETCH_DIRECTIVES[$directive]); + } + + return []; + } + + /** + * Get all directives + * + * @return array> + */ + public function getDirectives(): array + { + return $this->directives; + } + + /** + * Get the first nonce found in the directives. This can be used to set the nonce in a script or style tag + * + * @return ?string The first nonce found in the directives. + */ + public function getNonce(): ?string + { + return $this->nonce; + } + + /** + * Get the values of a directive as a single string + * + * @param string $directive The directive name + * + * @return ?string[] + */ + public function getRawDirective(string $directive): ?array + { + if (isset($this->directives[$directive])) { + return $this->directives[$directive]; + } + + return null; + } + + /** + * Check if a directive is present + * + * @param string $directive The directive name. Note that this can be a fetch directive, in which case the values + * will be fetched from the parent directive + */ + public function hasDirective(string $directive): bool + { + if (isset($this->directives[$directive])) { + return true; + } elseif (isset(static::FETCH_DIRECTIVES[$directive])) { + return $this->hasDirective(static::FETCH_DIRECTIVES[$directive]); + } + + return false; + } + + /** + * Check if a directive is present + * + * @param string $directive The directive name + */ + public function hasRawDirective(string $directive): bool + { + return isset($this->directives[$directive]); + } + + /** + * Check if the CSP does not contain any directives + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->directives === []; + } + + /** + * Merge multiple CSPs into the current one + * + * @param Csp ...$csps The CSPs to merge + * + * @return static + */ + public function merge(Csp ...$csps): static + { + foreach ($csps as $csp) { + foreach ($csp->directives as $directive => $values) { + $this->add($directive, $values === [] ? null : $values); + } + } + + return $this; + } + + public function __toString(): string + { + return $this->getHeader(); + } +} diff --git a/tests/Common/CspTest.php b/tests/Common/CspTest.php new file mode 100644 index 000000000..f4f441f4d --- /dev/null +++ b/tests/Common/CspTest.php @@ -0,0 +1,905 @@ +assertInstanceOf(Csp::class, $csp); + $this->assertTrue($csp->isEmpty()); + + $csp->add('script-src', "'self'"); + + $this->assertFalse($csp->isEmpty()); + } + + public function testAddString(): void + { + $csp = new Csp(); + + $csp->add('script-src', 'https://example.com'); + + $this->assertEquals(['https://example.com'], $csp->getDirective('script-src')); + } + + public function testAddStringEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', ''); + } + + public function testAddNull(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', null); + } + + public function testAddNullOnAllowedEmptyDirective(): void + { + $csp = new Csp(); + + $csp->add('sandbox', null); + + $this->assertEquals([], $csp->getDirective('sandbox')); + } + + public function testAddNullOnMandatoryEmptyDirective(): void + { + $csp = new Csp(); + + $csp->add('block-all-mixed-content', null); + + $this->assertEquals([], $csp->getDirective('block-all-mixed-content')); + } + + public function testAddStringOnMandatoryEmptyDirective(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('block-all-mixed-content', 'example'); + } + + public function testAddStringTrim(): void + { + $csp = new Csp(); + + $csp->add('script-src', ' https://example.com'); + + $this->assertEquals(['https://example.com'], $csp->getDirective('script-src')); + } + + public function testAddStringDuplicate(): void + { + $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(): void + { + $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 testAddStringCombinedMultipleSpaces(): void + { + $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(): void + { + $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 testFallbackToDefault(): void + { + $csp = new Csp(); + $csp->add('default-src', "'self'"); + + $this->assertEquals(["'self'"], $csp->getDirective('script-src')); + } + + public function testAddDirectiveNameCapitals(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('Default-src', 'https://example.com'); + } + + public function testAddDirectiveNameSpecialCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('default-src:', 'https://example.com'); + } + + public function testAddWildcardEverything(): void + { + $csp = new Csp(); + $csp->add('script-src', '*'); + + $this->assertEquals(['*'], $csp->getDirective('script-src')); + } + + public function testAddWildcard(): void + { + $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(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', "'self"); + } + + public function testAddMissingStartQuote(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', "self'"); + } + + public function testAddScheme(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://*'); + $csp->add('script-src', 'http:'); + + $this->assertEquals(['https://*', 'http:'], $csp->getDirective('script-src')); + } + + public function testAddReportingName(): void + { + $csp = new Csp(); + + $csp->add('report-to', 'reporting-endpoint'); + + $this->assertEquals(['reporting-endpoint'], $csp->getDirective('report-to')); + } + + #[DataProvider('providerInvalidWildcards')] + public function testAddInvalidWildcard(string $policy): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + + $csp->add('script-src', $policy); + } + + /** + * @return array> + */ + public static 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(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $csp->add('img-src', "'self'"); + + $this->assertEquals( + ['script-src' => ['https://example.com'], 'img-src' => ["'self'"]], + $csp->getDirectives(), + ); + } + + public function testGetHeader(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $csp->add('img-src', "'none'"); + + $this->assertEquals( + "script-src https://example.com; img-src 'none'", + $csp->getHeader() + ); + + $this->assertEquals( + $csp->getHeader(), + (string) $csp, + ); + } + + public function testGetHeaderWithNullableDirectives(): void + { + $csp = new Csp(); + $csp->add('sandbox', null); + + $this->assertEquals( + 'sandbox', + $csp->getHeader(), + ); + } + + public function testNonce(): void + { + $csp = new Csp(); + + $csp->add('style-src', "'nonce-example'"); + + $this->assertEquals('example', $csp->getNonce()); + } + + public function testNonceTwice(): void + { + $csp = new Csp(); + + $csp->add('style-src', "'nonce-example'"); + $csp->add('style-src', "'nonce-demo'"); + + $this->assertEquals('example', $csp->getNonce()); + } + + public function testNonceEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('style-src', "'nonce-'"); + } + + public function testFromString(): void + { + $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(), + ); + } + + public function testFromStringOptionalEmpty(): void + { + $csp = Csp::fromString("script-src 'nonce-example';\nsandbox;"); + + $this->assertEquals( + [ + 'script-src' => ["'nonce-example'"], + 'sandbox' => [], + ], + $csp->getDirectives(), + ); + } + + public function testFromStringOptionalEmptyWithValue(): void + { + $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(): void + { + $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(): void + { + $this->expectException(InvalidArgumentException::class); + + Csp::fromString("script-src 'nonce-example';\nblock-all-mixed-content foo;"); + } + + #[DataProvider('providerExpressionInjectionCharacters')] + public function testAddExpressionWithInjectionCharacterIsRejected(string $expression): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('script-src', $expression); + } + + /** + * @return array> + */ + public static function providerExpressionInjectionCharacters(): array + { + return [ + 'semicolon in URL injects directive' => ["https://example.com;object-src *"], + 'semicolon at end of URL' => ["https://example.com;"], + 'semicolon in quoted token' => ["'self';img-src *"], + 'semicolon in reporting name' => ["endpoint;object-src *"], + 'CR in URL enables header injection' => ["https://example.com\r"], + 'LF in URL enables header injection' => ["https://example.com\n"], + 'CRLF in URL enables header injection' => ["https://example.com\r\n"], + 'tab in URL expression' => ["https://example.com\t/path"], + 'CR in quoted token' => ["'self'\r"], + 'LF in quoted token' => ["'self'\n"], + 'tab in quoted token' => ["'self'\t"], + ]; + } + + #[DataProvider('providerInvalidQuotedExpressions')] + public function testAddInvalidQuotedExpressionIsRejected(string $expression): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('script-src', $expression); + } + + /** + * @return array> + */ + public static function providerInvalidQuotedExpressions(): array + { + return [ + 'arbitrary quoted string' => ["'foobar'"], + 'misspelled self' => ["'selff'"], + 'unknown keyword' => ["'unsafe-everything'"], + 'invalid hash algorithm' => ["'sha999-abc123'"], + 'sha without value' => ["'sha256-'"], + ]; + } + + public function testAddValidQuotedKeywordsAreAccepted(): void + { + $csp = new Csp(); + $csp->add('script-src', "'none'"); + $csp->add('script-src', "'unsafe-inline'"); + $csp->add('script-src', "'unsafe-eval'"); + $csp->add('script-src', "'unsafe-hashes'"); + $csp->add('script-src', "'strict-dynamic'"); + $csp->add('script-src', "'report-sample'"); + $csp->add('script-src', "'wasm-unsafe-eval'"); + + $this->assertEquals( + [ + "'none'", + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + "'strict-dynamic'", + "'report-sample'", + "'wasm-unsafe-eval'", + ], + $csp->getDirective('script-src'), + ); + } + + public function testAddValidHashSourcesAreAccepted(): void + { + $sha256 = "'sha256-abc123def456=='"; + $sha384 = "'sha384-abc123def456=='"; + $sha512 = "'sha512-abc123def456=='"; + + $csp = new Csp(); + $csp->add('script-src', $sha256); + $csp->add('script-src', $sha384); + $csp->add('script-src', $sha512); + + $this->assertEquals([$sha256, $sha384, $sha512], $csp->getDirective('script-src')); + } + + #[DataProvider('providerAllowedUnquotedKeywords')] + public function testAddUnquotedKeywordIsAccepted(string $directive, string $expression): void + { + $csp = new Csp(); + $csp->add($directive, $expression); + + $this->assertContains($expression, $csp->getDirective($directive)); + } + + /** + * @return array> + */ + public static function providerAllowedUnquotedKeywords(): array + { + return [ + 'sandbox allow-downloads' => ['sandbox', 'allow-downloads'], + 'sandbox allow-forms' => ['sandbox', 'allow-forms'], + 'sandbox allow-modals' => ['sandbox', 'allow-modals'], + 'sandbox allow-popups' => ['sandbox', 'allow-popups'], + 'sandbox allow-same-origin' => ['sandbox', 'allow-same-origin'], + 'sandbox allow-scripts' => ['sandbox', 'allow-scripts'], + 'report-to named endpoint' => ['report-to', 'reporting-endpoint'], + ]; + } + + public function testMergeSingleCspCopiesDirectives(): void + { + $a = new Csp(); + $a->add('script-src', 'https://example.com'); + $a->add('img-src', 'https://images.example.com'); + + $merged = new Csp(); + $merged->merge($a); + + $this->assertEquals(['https://example.com'], $merged->getDirective('script-src')); + $this->assertEquals(['https://images.example.com'], $merged->getDirective('img-src')); + } + + public function testMergeDoesNotMutateInputCsps(): void + { + $a = new Csp(); + $a->add('script-src', 'https://example.com'); + + $merged = new Csp(); + $merged->merge($a); + + $this->assertEquals(['https://example.com'], $a->getDirective('script-src')); + } + + public function testMergeCombinesNonOverlappingDirectives(): void + { + $a = new Csp(); + $a->add('script-src', 'https://scripts.example.com'); + + $b = new Csp(); + $b->add('img-src', 'https://images.example.com'); + + $merged = new Csp(); + $merged->merge($a, $b); + + $this->assertEquals(['https://scripts.example.com'], $merged->getDirective('script-src')); + $this->assertEquals(['https://images.example.com'], $merged->getDirective('img-src')); + } + + public function testMergeCombinesOverlappingDirectives(): void + { + $a = new Csp(); + $a->add('script-src', 'https://a.example.com'); + + $b = new Csp(); + $b->add('script-src', 'https://b.example.com'); + + $merged = new Csp(); + $merged->merge($a, $b); + + $this->assertEquals( + ['https://a.example.com', 'https://b.example.com'], + $merged->getDirective('script-src'), + ); + } + + public function testMergeDeduplicatesExpressions(): void + { + $a = new Csp(); + $a->add('script-src', 'https://example.com'); + + $b = new Csp(); + $b->add('script-src', 'https://example.com'); + + $merged = new Csp(); + $merged->merge($a, $b); + + $this->assertEquals(['https://example.com'], $merged->getDirective('script-src')); + } + + public function testMergeMultipleCsps(): void + { + $a = new Csp(); + $a->add('script-src', 'https://a.example.com'); + + $b = new Csp(); + $b->add('script-src', 'https://b.example.com'); + + $c = new Csp(); + $c->add('script-src', 'https://c.example.com'); + + $merged = new Csp(); + $merged->merge($a, $b, $c); + + $this->assertEquals( + ['https://a.example.com', 'https://b.example.com', 'https://c.example.com'], + $merged->getDirective('script-src'), + ); + } + + public function testMergePreservesOptionalEmptyDirective(): void + { + $a = new Csp(); + $a->add('sandbox', null); + + $merged = new Csp(); + $merged->merge($a); + + $this->assertEquals([], $merged->getDirective('sandbox')); + } + + public function testMergePreservesMandatoryEmptyDirective(): void + { + $a = new Csp(); + $a->add('block-all-mixed-content', null); + + $merged = new Csp(); + $merged->merge($a); + + $this->assertEquals([], $merged->getDirective('block-all-mixed-content')); + } + + public function testMergePreservesEmptyDirectiveAlongsideNonEmpty(): void + { + $a = new Csp(); + $a->add('sandbox', null); + $a->add('script-src', 'https://example.com'); + + $merged = new Csp(); + $merged->merge($a); + + $this->assertEquals([], $merged->getDirective('sandbox')); + $this->assertEquals(['https://example.com'], $merged->getDirective('script-src')); + } + + #[DataProvider('providerSchemelessHostSources')] + public function testAddSchemelessHostSourceIsAccepted(string $expression): void + { + $csp = new Csp(); + $csp->add('script-src', $expression); + + $this->assertContains($expression, $csp->getDirective('script-src')); + } + + /** + * @return array> + */ + public static function providerSchemelessHostSources(): array + { + return [ + 'plain host' => ['example.com'], + 'wildcard subdomain' => ['*.example.com'], + 'host with path' => ['example.com/path'], + ]; + } + + #[DataProvider('providerInvalidPorts')] + public function testAddExpressionWithInvalidPortIsRejected(string $expression): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('script-src', $expression); + } + + /** + * @return array> + */ + public static function providerInvalidPorts(): array + { + return [ + 'zero port with scheme' => ['https://example.com:0'], + 'negative port with scheme' => ['https://example.com:-1'], + 'port too large with scheme' => ['https://example.com:65536'], + 'zero port schemeless' => ['example.com:0'], + 'negative port schemeless' => ['example.com:-1'], + 'port too large schemeless' => ['example.com:65536'], + ]; + } + + public function testInvalidNonceDoesNotMutateState(): void + { + $csp = new Csp(); + + try { + $csp->add('style-src', "'nonce-'"); + } catch (InvalidArgumentException) { + } + + $this->assertArrayNotHasKey('style-src', $csp->getDirectives()); + $this->assertNull($csp->getNonce()); + } + + public function testInvalidNonceDoesNotAppendToExistingDirective(): void + { + $csp = new Csp(); + $csp->add('style-src', "'self'"); + + try { + $csp->add('style-src', "'nonce-'"); + } catch (InvalidArgumentException) { + } + + $this->assertEquals(["'self'"], $csp->getDirective('style-src')); + $this->assertNull($csp->getNonce()); + } + + public function testGetDirectiveDefaultSrcReturnsDefaultSourceExpressions(): void + { + $csp = new Csp(); + $csp->add('default-src', "'self'"); + + $this->assertEquals(["'self'"], $csp->getDirective('default-src')); + } + + public function testGetDirectiveUnsetFetchDirectiveFallsBackToDefaultSrc(): void + { + $csp = new Csp(); + $csp->add('default-src', "'self'"); + + $this->assertEquals(["'self'"], $csp->getDirective('img-src')); + } + + public function testGetDirectiveUsesOwnValueWhenExplicitlySet(): void + { + $csp = new Csp(); + $csp->add('img-src', 'https://images.example.com'); + $this->assertEquals(['https://images.example.com'], $csp->getDirective('img-src')); + } + + public function testGetDirectiveChildFallsBackToIntermediateParent(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://scripts.example.com'); + $this->assertEquals(['https://scripts.example.com'], $csp->getDirective('script-src-elem')); + } + + public function testGetDirectiveChildFallsBackThroughFullInheritanceChain(): void + { + $csp = new Csp(); + $csp->add('default-src', "'self'"); + + $this->assertEquals(["'self'"], $csp->getDirective('script-src-elem')); + } + + public function testGetDirectiveStyleSrcAttrFallsBackToStyleSrc(): void + { + $csp = new Csp(); + $csp->add('style-src', 'https://styles.example.com'); + $this->assertEquals(['https://styles.example.com'], $csp->getDirective('style-src-attr')); + } + + public function testGetRawDirectiveReturnsNullWhenNotSet(): void + { + $csp = new Csp(); + $this->assertNull($csp->getRawDirective('script-src')); + } + + public function testGetRawDirectiveReturnsValuesWhenSet(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $this->assertEquals(['https://example.com'], $csp->getRawDirective('script-src')); + } + + public function testGetRawDirectiveDoesNotInheritFromParent(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://scripts.example.com'); + $this->assertNull($csp->getRawDirective('script-src-elem')); + } + + public function testGetRawDirectiveReturnsEmptyArrayForEmptyDirective(): void + { + $csp = new Csp(); + $csp->add('sandbox', null); + $this->assertEquals([], $csp->getRawDirective('sandbox')); + } + + public function testHasDirectiveReturnsTrueWhenExplicitlySet(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $this->assertTrue($csp->hasDirective('script-src')); + } + + public function testHasDirectiveReturnsTrueWhenParentIsSet(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://scripts.example.com'); + $this->assertTrue($csp->hasDirective('script-src-elem')); + } + + public function testHasDirectiveReturnsFalseWhenNeitherDirectiveNorParentIsSet(): void + { + $csp = new Csp(); + $this->assertFalse($csp->hasDirective('script-src-elem')); + } + + public function testHasRawDirectiveReturnsTrueWhenSet(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com'); + $this->assertTrue($csp->hasRawDirective('script-src')); + } + + public function testHasRawDirectiveReturnsFalseWhenNotSet(): void + { + $csp = new Csp(); + $this->assertFalse($csp->hasRawDirective('script-src')); + } + + public function testHasRawDirectiveDoesNotInheritFromParent(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://scripts.example.com'); + $this->assertFalse($csp->hasRawDirective('script-src-elem')); + } + + public function testHasRawDirectiveReturnsTrueForEmptyDirective(): void + { + $csp = new Csp(); + $csp->add('sandbox', null); + $this->assertTrue($csp->hasRawDirective('sandbox')); + } + + public function testFromStringRoundTrip(): void + { + $csp = new Csp(); + $csp->add('script-src', "'self'"); + $csp->add('script-src', 'https://example.com'); + $csp->add('img-src', 'https://images.example.com'); + $csp->add('sandbox', null); + + $parsed = Csp::fromString($csp->getHeader()); + + $this->assertEquals($csp->getDirectives(), $parsed->getDirectives()); + } + + public function testFromStringWithDefaultSrc(): void + { + $csp = Csp::fromString("default-src 'self'"); + + $this->assertEquals(["'self'"], $csp->getDirective('default-src')); + $this->assertEquals(["'self'"], $csp->getDirective('img-src')); + } + + public function testFirstNonceWinsInMergedCsp(): void + { + $a = new Csp(); + $a->add('script-src', "'nonce-first'"); + + $b = new Csp(); + $b->add('script-src', "'nonce-second'"); + + $merged = new Csp(); + $merged->merge($a, $b); + + $this->assertEquals('first', $merged->getNonce()); + } + + public function testFirstNonceWinsInParsedHeader(): void + { + $csp = Csp::fromString("script-src 'nonce-first'; style-src 'nonce-second'"); + + $this->assertEquals('first', $csp->getNonce()); + } + + public function testAddWildcardPort(): void + { + $csp = new Csp(); + $csp->add('script-src', 'https://example.com:*'); + + $this->assertEquals(['https://example.com:*'], $csp->getDirective('script-src')); + } + + public function testNonFetchDirectiveDoesNotFallBack(): void + { + $csp = new Csp(); + $csp->add('default-src', "'self'"); + + $this->assertEquals([], $csp->getDirective('form-action')); + $this->assertEquals([], $csp->getDirective('frame-ancestors')); + } + + public function testUpgradeInsecureRequests(): void + { + $csp = new Csp(); + $csp->add('upgrade-insecure-requests', null); + + $this->assertEquals([], $csp->getRawDirective('upgrade-insecure-requests')); + $this->assertEquals('upgrade-insecure-requests', $csp->getHeader()); + } + + public function testUpgradeInsecureRequestsWithValueIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $csp = new Csp(); + $csp->add('upgrade-insecure-requests', 'something'); + } + + public function testGetNonceReturnsNullWhenNoNonceExists(): void + { + $csp = new Csp(); + + $this->assertNull($csp->getNonce()); + } + + public function testAddAtomicityForInvalidExpression(): void + { + $csp = new Csp(); + + try { + $csp->add('script-src', "'foobar'"); + } catch (InvalidArgumentException) { + } + + $this->assertArrayNotHasKey('script-src', $csp->getDirectives()); + } + + public function testAddAtomicityForInvalidExpressionPreservesExistingValues(): void + { + $csp = new Csp(); + $csp->add('script-src', "'self'"); + + try { + $csp->add('script-src', "'foobar'"); + } catch (InvalidArgumentException) { + } + + $this->assertEquals(["'self'"], $csp->getDirective('script-src')); + } +}