*/ protected Map $directives; /** * @param SourceCollection|SourceInterface ...$sources (optional) default-src sources */ public function __construct(SourceCollection|SourceInterface ...$sources) { $this->directives = new Map(); $directive = Directive::DefaultSrc; $collection = $this->asMergedSourceCollection(...$sources); $collection = $this->purgeNonApplicableSources($directive, $collection); if (!$collection->isEmpty()) { $this->directives[$directive] = $collection; } } public function isEmpty(): bool { return count($this->directives) === 0; } /** * Applies mutations/changes to the current policy. */ public function mutate(MutationCollection|Mutation ...$mutations): self { $self = $this; foreach ($mutations as $mutation) { if ($mutation instanceof MutationCollection) { $self = $self->mutate(...$mutation->mutations); } elseif ($mutation->mode === MutationMode::Set) { $self = $self->set($mutation->directive, ...$mutation->sources); } elseif ($mutation->mode === MutationMode::Append) { $self = $self->append($mutation->directive, ...$mutation->sources); } elseif ($mutation->mode === MutationMode::InheritOnce) { $self = $self->inherit($mutation->directive); } elseif ($mutation->mode === MutationMode::InheritAgain) { $self = $self->inherit($mutation->directive, true); } elseif ($mutation->mode === MutationMode::Extend) { $self = $self->extend($mutation->directive, ...$mutation->sources); } elseif ($mutation->mode === MutationMode::Reduce) { $self = $self->reduce($mutation->directive, ...$mutation->sources); } elseif ($mutation->mode === MutationMode::Remove) { $self = $self->remove($mutation->directive); } } return $self; } /** * Sets (overrides) the 'default-src' directive, which is also the fall-back for other more specific directives. */ public function default(SourceCollection|SourceInterface ...$sources): self { return $this->set(Directive::DefaultSrc, ...$sources); } /** * Appends to an existing directive, or a new source collection in case it was empty. */ public function append(Directive $directive, SourceCollection|SourceInterface ...$sources): self { $collection = $this->asMergedSourceCollection(...$sources); $collection = $this->purgeNonApplicableSources($directive, $collection); if ($collection->isEmpty() && !$directive->isStandAlone()) { return $this; } $targetCollection = $this->asMergedSourceCollection(...array_filter([ $this->directives[$directive] ?? null, $collection, ])); return $this->changeDirectiveSources($directive, $targetCollection); } /** * Inherits the current source collection of the closest non-empty ancestor in the chain. * * @param bool $again whether to inherit again and merge with the existing source collection */ public function inherit(Directive $directive, bool $again = false): self { $currentSources = $this->directives[$directive] ?? null; if ($again || $currentSources === null) { foreach ($directive->getAncestors() as $ancestorDirective) { if ($this->has($ancestorDirective)) { $ancestorCollection = $this->directives[$ancestorDirective]; break; } } } $targetCollection = $this->asMergedSourceCollection(...array_filter([ $ancestorCollection ?? null, $currentSources, ])); return $this->changeDirectiveSources($directive, $targetCollection); } /** * Extends a specific directive, either by appending sources or by inheriting from an ancestor directive. */ public function extend(Directive $directive, SourceCollection|SourceInterface ...$sources): self { return $this->inherit($directive)->append($directive, ...$sources); } public function reduce(Directive $directive, SourceCollection|SourceInterface ...$sources): self { if (!$this->has($directive)) { return $this; } $collection = $this->asMergedSourceCollection(...$sources); $targetCollection = $this->directives[$directive]->exclude($collection); return $this->changeDirectiveSources($directive, $targetCollection); } /** * Sets (overrides) a specific directive. */ public function set(Directive $directive, SourceCollection|SourceInterface ...$sources): self { $collection = $this->asMergedSourceCollection(...$sources); $collection = $this->purgeNonApplicableSources($directive, $collection); return $this->changeDirectiveSources($directive, $collection); } /** * Removes a specific directive. */ public function remove(Directive $directive): self { if (!$this->has($directive)) { return $this; } $target = clone $this; unset($target->directives[$directive]); return $target; } /** * Sets the 'report-uri' directive and appends 'report-sample' to existing & applicable directives. */ public function report(UriValue $reportUri): self { $target = $this->set(Directive::ReportUri, $reportUri); $reportSample = SourceKeyword::reportSample; foreach ($target->directives as $directive => $collection) { if ($reportSample->isApplicable($directive)) { $target->directives[$directive] = $collection->with($reportSample); } } return $target; } public function has(Directive $directive): bool { return isset($this->directives[$directive]); } public function get(Directive $directive): ?SourceCollection { return $this->directives[$directive] ?? null; } /** * Prepares the policy for finally being serialized and issued as HTTP header. * This step aims to optimize several combinations, or adjusts directives when 'strict-dynamic' is used. */ public function prepare(PolicyBag $policyBag): self { $hashCollection = $policyBag->directiveHashCollection; $useHash = $policyBag->behavior->useHash; $useNonce = $policyBag->behavior->useNonce; // Apply collected asset hashes as mutations when hashes are enabled $target = ($useHash && !$hashCollection->isEmpty()) ? $this->mutate(...$hashCollection->asMutationCollections()) : $this; $nonceProxyDirectives = SourceKeyword::nonceProxy->getApplicableDirectives(); $directives = clone $target->directives; $comparator = [$this, 'compareSources']; /** * @var Directive $directive * @var SourceCollection $collection */ foreach ($directives as $directive => $collection) { if (!in_array($directive, $nonceProxyDirectives, true)) { continue; } $containsNonceProxy = $collection->contains(SourceKeyword::nonceProxy); if ($useNonce === false && $containsNonceProxy) { $directives[$directive] = $collection->without(SourceKeyword::nonceProxy); } } foreach ($directives as $directive => $collection) { foreach ($directive->getAncestors() as $ancestorDirective) { $ancestorCollection = $directives[$ancestorDirective] ?? null; if ($ancestorCollection !== null && array_udiff($collection->sources, $ancestorCollection->sources, $comparator) === [] && array_udiff($ancestorCollection->sources, $collection->sources, $comparator) === [] ) { unset($directives[$directive]); continue 2; } } } foreach ($directives as $directive => $collection) { // applies implicit changes to sources in case 'strict-dynamic' is used for applicable directives if ($collection->contains(SourceKeyword::strictDynamic) && SourceKeyword::strictDynamic->isApplicable($directive)) { if ($useNonce === false) { $directives[$directive] = $collection->without(SourceKeyword::strictDynamic, SourceKeyword::nonceProxy); } else { // @todo strict-dynamic either needs hashes or nonces $directives[$directive] = SourceKeyword::strictDynamic->applySourceImplications($collection) ?? $collection; } } } $result = clone $target; $result->directives = $directives; return $result; } /** * Compiles this policy and returns the serialized representation to be used as HTTP header value. * * @param ?FrontendInterface $cache to be used for storing compiled CSP aspects (disabled in install tool) */ public function compile(PolicyBag $policyBag, ?FrontendInterface $cache = null): string { $nonce = $policyBag->nonce; $policyParts = []; $service = GeneralUtility::makeInstance(ModelService::class, $cache); foreach ($this->prepare($policyBag)->directives as $directive => $collection) { $directiveParts = $service->compileSources($nonce, $collection); if ($directiveParts !== [] || $directive->isStandAlone()) { array_unshift($directiveParts, $directive->value); $policyParts[] = implode(' ', $directiveParts); } } return implode('; ', $policyParts); } /** * Determines whether all sources are contained (in terms of instances and values, but without inference). */ public function containsDirective(Directive $directive, SourceCollection|SourceInterface ...$sources): bool { $sources = $this->asMergedSourceCollection(...$sources); return (bool)$this->directives[$directive]?->contains(...$sources->sources); } /** * Determines whether all sources are covered (in terms of CSP inference, considering wildcards and similar). */ public function coversDirective(Directive $directive, SourceCollection|SourceInterface ...$sources): bool { $sources = $this->asMergedSourceCollection(...$sources); return (bool)$this->directives[$directive]?->covers(...$sources->sources); } /** * Whether the current policy contains another policy (in terms of instances and values, but without inference). */ public function contains(Policy $other): bool { if ($other->isEmpty()) { return false; } foreach ($other->directives as $directive => $collection) { if (!$this->containsDirective($directive, $collection)) { return false; } } return true; } /** * Whether the current policy covers another policy (in terms of CSP inference, considering wildcards and similar). */ public function covers(Policy $other): bool { if ($other->isEmpty()) { return false; } foreach ($other->directives as $directive => $collection) { if (!$this->coversDirective($directive, $collection)) { return false; } } return true; } protected function compareSources(SourceInterface $a, SourceInterface $b): int { $service = GeneralUtility::makeInstance(ModelService::class); return $service->serializeSource($a) <=> $service->serializeSource($b); } protected function changeDirectiveSources(Directive $directive, SourceCollection $sources): self { $isAlreadyEmpty = empty($this->directives[$directive]) || $this->directives[$directive]->isEmpty(); if ($isAlreadyEmpty && $sources->isEmpty() && !$directive->isStandAlone()) { return $this; } $target = clone $this; $target->directives[$directive] = $sources; return $target; } protected function asMergedSourceCollection(SourceCollection|SourceInterface ...$subjects): SourceCollection { $collections = array_filter($subjects, static fn($source) => $source instanceof SourceCollection); $sources = array_filter($subjects, static fn($source) => !$source instanceof SourceCollection); if ($sources !== []) { $collections[] = new SourceCollection(...$sources); } $target = new SourceCollection(); foreach ($collections as $collection) { $target = $target->merge($collection); } return $target; } protected function purgeNonApplicableSources(Directive $directive, SourceCollection $collection): SourceCollection { $sources = array_filter( $collection->sources, static fn(SourceInterface $source): bool => $source instanceof SourceKeyword ? $source->isApplicable($directive) : true ); return new SourceCollection(...$sources); } }