369 lines
14 KiB
PHP
369 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy;
|
|
|
|
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
|
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag;
|
|
use TYPO3\CMS\Core\Type\Map;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Representation of the whole Content-Security-Policy
|
|
* see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
|
*
|
|
* @internal This implementation still might be adjusted
|
|
*/
|
|
class Policy
|
|
{
|
|
/**
|
|
* @var Map<Directive, SourceCollection>
|
|
*/
|
|
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);
|
|
}
|
|
}
|