-
Notifications
You must be signed in to change notification settings - Fork 2
Implement Csp #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TheSyscall
wants to merge
23
commits into
main
Choose a base branch
from
cps
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+648
−0
Open
Implement Csp #361
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
72bbd6f
Implement Csp
TheSyscall 80c996d
Allow newlines in Csp::fromString
TheSyscall 1ae4773
Review suggestions
TheSyscall cee77a5
Specify array type
TheSyscall d18f301
Add isEmpty method that checks if the array of directives is empty
TheSyscall 53ec3d6
Add tests
TheSyscall 9b843a4
Use constation for default-src policies
TheSyscall ac95455
Add policy validation and url evaluation
TheSyscall 9f4dd3a
Remove unnecessary space check
TheSyscall c8934de
Add more tests
TheSyscall 0fd66a2
Code style changes
TheSyscall 1224df7
Change dataprovider to use attribute
TheSyscall e9da226
fixup! make data provider static
TheSyscall 2ae7a62
fixup! phpstan changes
TheSyscall c7e9650
fixup! add check to test if path can be extended
TheSyscall 7083652
code review suggestions
TheSyscall 18f914f
rename policy to expression to better match the spec
TheSyscall e56c1af
Allow certain directives to be empty
TheSyscall 1e38333
fixup! phpcs
TheSyscall 022d1ac
Remove `evaluateUrl`
TheSyscall e1d2234
Apply suggestions from code review
TheSyscall 0f22bff
Replace isset with ??=
TheSyscall 60480a8
Remove extra '' check
TheSyscall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,297 @@ | ||
| <?php | ||
|
|
||
| namespace ipl\Web\Common; | ||
|
|
||
| use InvalidArgumentException; | ||
|
|
||
| /** | ||
| * Represents a Content Security Policy (CSP) header. | ||
| * Methods are additive, and duplicate expressions are ignored. | ||
| */ | ||
| class Csp | ||
|
Al2Klimov marked this conversation as resolved.
|
||
| { | ||
| /** @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<string, array<string>> 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
| * | ||
| * @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", ' ', $header); | ||
| $header = str_replace("\n", ' ', $header); | ||
| $result = new static(); | ||
| foreach (explode(';', $header) as $directive) { | ||
| $directive = trim($directive); | ||
| if (empty($directive)) { | ||
| continue; | ||
| } | ||
| $parts = explode(' ', $directive, 2); | ||
| $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]); | ||
| } | ||
| } | ||
|
|
||
| return $result; | ||
| } | ||
|
|
||
| /** | ||
| * Add a directive with a 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 | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function add(string $directive, string|array|null $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 ($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." | ||
| ); | ||
| } | ||
| $this->directives[$directive] ??= []; | ||
| } elseif (is_string($value)) { | ||
| $value = trim($value); | ||
|
|
||
| if (str_contains($value, ' ')) { | ||
| return $this->add($directive, explode(' ', $value)); | ||
| } | ||
|
|
||
| $this->validateExpression($value); | ||
|
|
||
| if (in_array($value, $this->directives[$directive] ?? [])) { | ||
| return $this; | ||
| } | ||
|
|
||
| $this->directives[$directive] ??= []; | ||
|
|
||
| $this->directives[$directive][] = $value; | ||
|
|
||
| if ( | ||
| $this->nonce === null | ||
| && str_starts_with($value, "'nonce-") | ||
| && str_ends_with($value, "'") | ||
| ) { | ||
| $nonce = substr($value, 7, -1); | ||
| if (empty($nonce)) { | ||
| throw new InvalidArgumentException("Nonce must have a value."); | ||
| } | ||
|
|
||
| $this->nonce = $nonce; | ||
| } | ||
| } 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 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_EXPRESSIONS; | ||
| } | ||
|
|
||
| /** | ||
| * Get all directives | ||
| * | ||
| * @return array<string, array<string>> | ||
| */ | ||
| public function getDirectives(): array | ||
| { | ||
| return $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 " . implode(' ', static::DEFAULT_SOURCE_EXPRESSIONS)]; | ||
| foreach ($this->directives as $directive => $expressions) { | ||
| $directiveStrings[] = implode(' ', array_merge([$directive], $expressions)); | ||
| } | ||
| return implode('; ', $directiveStrings); | ||
| } | ||
|
|
||
| public function __toString(): string | ||
| { | ||
| return $this->getHeader(); | ||
| } | ||
|
|
||
| public function isEmpty(): bool | ||
| { | ||
| return empty($this->directives); | ||
| } | ||
|
|
||
| /** | ||
| * Validate an expression. Throws an exception if the expression is invalid. | ||
| * | ||
| * @param string $expression The expression to validate | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function validateExpression(string $expression): void | ||
| { | ||
| if ($expression === '') { | ||
| throw new InvalidArgumentException("Expression must not be empty."); | ||
| } | ||
|
|
||
| if ($expression === '*') { | ||
| 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, "'")) { | ||
| return; | ||
| } | ||
|
|
||
| // scheme: and scheme://* | ||
| if (preg_match('/^[a-z]+:(\/\/\*)?$/', $expression)) { | ||
| return; | ||
| } | ||
|
|
||
| // Reporting names | ||
| if (preg_match('/^[a-zA-Z0-9_-]+$/', $expression)) { | ||
| return; | ||
| } | ||
|
|
||
| $parsedUrl = parse_url($expression); | ||
| if ($parsedUrl === false) { | ||
| throw new InvalidArgumentException("Expression must be a valid URL. Expression: $expression"); | ||
| } | ||
|
|
||
| if (! isset($parsedUrl['host'])) { | ||
| throw new InvalidArgumentException("Expression URL must specify a host. Expression: $expression"); | ||
| } | ||
|
|
||
| if (! isset($parsedUrl['scheme'])) { | ||
| 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. Expression: $expression"); | ||
| } | ||
| } else { | ||
| if (str_contains($parsedUrl['host'], '*')) { | ||
| throw new InvalidArgumentException( | ||
| "Wildcards can only be used at the start of the host. Expression: $expression", | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.