TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
@@ -0,0 +1,59 @@
<?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\Configuration;
/**
* @internal
*/
final class Behavior
{
/**
* @param bool|null $useNonce Whether to use nonce values
* @param bool|null $useHash Whether to use hash values
*/
public function __construct(
/**
* Controls nonce usage. null = system decides per context, true = always use nonce, false = never use nonce.
*/
public ?bool $useNonce = null,
/**
* Whether to collect CSP hash values for assets. Always true by default because hashes enable
* response caching (unlike nonces which are per-request). Even when nonces are used, hashes are
* still collected so that cached responses include the correct CSP directives.
*/
public ?bool $useHash = null,
) {}
/**
* Creates a Behavior instance from a `csp.yaml` `behavior:` section.
*
* Example:
* ```yaml
* behavior:
* useNonce: false
* useHash: true
* ```
*/
public static function fromArray(array $data): self
{
$useNonce = isset($data['useNonce']) ? (bool)$data['useNonce'] : null;
$useHash = isset($data['useHash']) ? (bool)$data['useHash'] : null;
return new self($useNonce, $useHash);
}
}
@@ -0,0 +1,126 @@
<?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\Configuration;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition;
use TYPO3\CMS\Core\Type\Map;
/**
* Transforms a `csp.yaml` site configuration into a configuration model.
*
* @internal
*/
readonly class CspConfigurationFactory
{
public function __construct(private Features $features) {}
/**
* @return list<Disposition>
*/
public function resolveFallbackDispositions(): array
{
$dispositions = [];
if ($this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) {
$dispositions[] = Disposition::enforce;
}
if ($this->features->isFeatureEnabled('security.frontend.reportContentSecurityPolicy')) {
$dispositions[] = Disposition::report;
}
return $dispositions;
}
/**
* Builds a Behavior instance from the top-level `behavior:` section of the `csp.yaml` configuration.
*/
public function buildBehavior(array $siteConfiguration): Behavior
{
$behaviorData = $siteConfiguration['behavior'] ?? [];
return is_array($behaviorData) ? Behavior::fromArray($behaviorData) : new Behavior();
}
/**
* @return Map<Disposition, DispositionConfiguration>
*/
public function buildDispositionMap(array $siteConfiguration): Map
{
$activeAssignment = (bool)($siteConfiguration['active'] ?? true);
// @todo future TYPO3 v14 should explicitly require `active: true` to get rid of the feature fallbacks
if ($activeAssignment === false) {
return new Map();
}
$dispositions = new Map();
// assign site-specific dispositions
foreach (Disposition::cases() as $disposition) {
$assignment = $siteConfiguration[$disposition->value] ?? null;
if ($this->isActive($assignment)) {
$dispositions[$disposition] = $this->buildDispositionConfiguration(
$assignment,
$siteConfiguration
);
}
}
// in case there is no site-specific configuration, use the fallbacks as defined by top-level features
if (count($dispositions) === 0) {
foreach ($this->resolveFallbackDispositions() as $fallbackDisposition) {
// skip fallbacks in case the disposition was disabled explicitly (e.g. `enforce: false`)
if (($siteConfiguration[$fallbackDisposition->value] ?? null) !== false) {
$dispositions[$fallbackDisposition] = $this->buildDispositionConfiguration(
true,
$siteConfiguration
);
}
}
}
return $dispositions;
}
private function isActive(mixed $assignment): bool
{
return $assignment === true || is_array($assignment);
}
private function buildDispositionConfiguration(
true|array $assignment,
array $siteConfiguration = []
): DispositionConfiguration {
if ($assignment === true) {
// take from top-level configuration
// (`includeResolutions` and `packages` are ignored on purpose)
$inheritDefault = $siteConfiguration['inheritDefault'] ?? true;
$includeResolutions = true;
$reportingUrl = null;
$mutations = $siteConfiguration['mutations'] ?? [];
$packages = [];
} else {
$inheritDefault = $assignment['inheritDefault'] ?? true;
$includeResolutions = $assignment['includeResolutions'] ?? true;
$reportingUrl = $assignment['reportingUrl'] ?? null;
$mutations = $assignment['mutations'] ?? [];
$packages = $assignment['packages'] ?? [];
}
return new DispositionConfiguration(
(bool)$inheritDefault,
(bool)$includeResolutions,
$reportingUrl,
is_array($mutations) ? $mutations : [],
is_array($packages) ? $packages : [],
);
}
}
@@ -0,0 +1,76 @@
<?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\Configuration;
/**
* Represents a `csp.yaml` site configuration section for the disposition modes `enforce` and `report.
*
* @internal
*/
readonly class DispositionConfiguration
{
public bool|string|null $reportingUrl;
public function __construct(
public bool $inheritDefault,
public bool $includeResolutions,
mixed $reportingUrl,
public array $mutations = [],
/** @var array<string, bool> $packages */
public array $packages = [],
) {
$this->reportingUrl = self::normalizeReportingUrl($reportingUrl);
}
public static function normalizeReportingUrl(mixed $reportingUrl): bool|string|null
{
if ($reportingUrl === null || is_bool($reportingUrl)) {
return $reportingUrl;
}
if (!is_scalar($reportingUrl)) {
return null;
}
if ($reportingUrl === 0 || $reportingUrl === '0') {
return false;
}
if ($reportingUrl === 1 || $reportingUrl === '1') {
return true;
}
return (string)$reportingUrl;
}
public function resolveEffectivePackages(string ...$packageNames): array
{
if ($this->packages === []) {
return $packageNames;
}
$effectivePackageNames = [];
if (($this->packages['*'] ?? null) === true) {
$effectivePackageNames = $packageNames;
}
$dropPackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === false);
$effectivePackageNames = array_diff($effectivePackageNames, $dropPackageNames);
$includePackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === true);
$effectivePackageNames = [...$effectivePackageNames, ...array_diff($includePackageNames, $effectivePackageNames)];
return $effectivePackageNames;
}
}
@@ -0,0 +1,100 @@
<?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\Type\Map;
use TYPO3\CMS\Core\Utility\StringUtility;
final class ConsumableNonce implements \Countable, \Stringable
{
private const int MIN_BYTES = 40;
/**
* @internal use the more specific methods `consumeInline()` or `consumeStatic()` instead
*/
public readonly string $value;
/**
* @var Map<mixed, int>
*/
private Map $inlineCount;
/**
* @var Map<mixed, int>
*/
private Map $staticCount;
public function __construct(?string $value = null)
{
if ($value === null || strlen($value) < self::MIN_BYTES) {
$value = random_bytes(self::MIN_BYTES);
$value = StringUtility::base64urlEncode($value);
}
$this->value = $value;
$this->inlineCount = new Map();
$this->staticCount = new Map();
}
public function __toString(): string
{
return $this->consumeInline();
}
public function count(): int
{
return $this->countInline() + $this->countStatic();
}
public function countInline(mixed $aspect = null): int
{
if ($aspect === null) {
return array_sum($this->inlineCount->values());
}
return $this->inlineCount[$aspect] ?? 0;
}
public function countStatic(mixed $aspect = null): int
{
if ($aspect === null) {
return array_sum($this->staticCount->values());
}
return $this->staticCount[$aspect] ?? 0;
}
/**
* @internal consider using the more specific methods `consumeInline()` or `consumeStatic()` instead
*/
public function consume(): string
{
return $this->consumeInline();
}
public function consumeInline(mixed $aspect = 'default'): string
{
// `\TYPO3\CMS\Core\Type\Map::offsetGet would` have to be `&offsetGet` for increments to work
$this->inlineCount[$aspect] = ($this->inlineCount[$aspect] ?? 0) + 1;
return $this->value;
}
public function consumeStatic(mixed $aspect = 'default'): string
{
// `\TYPO3\CMS\Core\Type\Map::offsetGet would` have to be `&offsetGet` for increments to work
$this->staticCount[$aspect] = ($this->staticCount[$aspect] ?? 0) + 1;
return $this->value;
}
}
@@ -0,0 +1,28 @@
<?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;
/**
* Interface to determine whether a value is covered by some other value in the scope of CSP,
* e.g. URI `*.example.com` would "cover" URI `https://specific.example.com/path/file.js`
* @internal
*/
interface CoveringInterface
{
public function covers(CoveringInterface $other): bool;
}
@@ -0,0 +1,143 @@
<?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;
/**
* Representation of Content-Security-Policy directives
* see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives
*/
enum Directive: string
{
case DefaultSrc = 'default-src';
case BaseUri = 'base-uri';
case ChildSrc = 'child-src';
case ConnectSrc = 'connect-src';
case FontSrc = 'font-src';
case FormAction = 'form-action';
case FrameAncestors = 'frame-ancestors';
case FrameSrc = 'frame-src';
case ImgSrc = 'img-src';
case ManifestSrc = 'manifest-src';
case MediaSrc = 'media-src';
case ObjectSrc = 'object-src';
// @deprecated (used for Safari, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types)
case PluginTypes = 'plugin-types';
case ReportTo = 'report-to';
// @deprecated (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri)
// but `report-uri` is still used for being compatible other older browsers
case ReportUri = 'report-uri';
case RequireTrustedTypesFor = 'require-trusted-types-for';
case Sandbox = 'sandbox';
case ScriptSrc = 'script-src';
case ScriptSrcAttr = 'script-src-attr';
case ScriptSrcElem = 'script-src-elem';
case StyleSrc = 'style-src';
case StyleSrcAttr = 'style-src-attr';
case StyleSrcElem = 'style-src-elem';
case TrustedTypes = 'trusted-types';
case UpgradeInsecureRequests = 'upgrade-insecure-requests';
case WorkerSrc = 'worker-src';
private const STAND_ALONE = [
self::Sandbox,
self::TrustedTypes,
self::UpgradeInsecureRequests,
];
/**
* @return list<self>
*/
public function getAncestors(): array
{
return self::ancestorMap()[$this] ?? [];
}
/**
* @return list<self>
* @internal
*/
public function getFamily(): array
{
$family = [$this];
foreach (self::ancestorMap() as $child => $ancestors) {
if (in_array($this, $ancestors, true)) {
$family[] = $child;
}
}
return $family;
}
/**
* Determines whether a mutation for the current directive would be reasonable.
* For instance, changing the `default-src` or `report-uri` would not qualify.
*/
public function isMutationReasonable(): bool
{
return in_array($this, self::reasonableMutationItems(), true);
}
/**
* Determines whether the current directive can be used without any values,
* like for instance `sandbox`, `trusted-types` or `upgrade-insecure-requests`.
*/
public function isStandAlone(): bool
{
return in_array($this, self::STAND_ALONE, true);
}
/**
* @return \WeakMap<self, list<self>>
*/
private static function ancestorMap(): \WeakMap
{
/** @var \WeakMap<self, list<self>> $map temporary, internal \WeakMap */
$map = new \WeakMap();
$map[self::ChildSrc] = [self::DefaultSrc];
$map[self::ConnectSrc] = [self::DefaultSrc];
$map[self::FontSrc] = [self::DefaultSrc];
$map[self::FrameSrc] = [self::ChildSrc, self::DefaultSrc];
$map[self::ImgSrc] = [self::DefaultSrc];
$map[self::ManifestSrc] = [self::DefaultSrc];
$map[self::MediaSrc] = [self::DefaultSrc];
$map[self::ObjectSrc] = [self::DefaultSrc];
$map[self::ScriptSrc] = [self::DefaultSrc];
$map[self::ScriptSrcAttr] = [self::ScriptSrc, self::DefaultSrc];
$map[self::ScriptSrcElem] = [self::ScriptSrc, self::DefaultSrc];
$map[self::StyleSrc] = [self::DefaultSrc];
$map[self::StyleSrcAttr] = [self::StyleSrc, self::DefaultSrc];
$map[self::StyleSrcElem] = [self::StyleSrc, self::DefaultSrc];
$map[self::WorkerSrc] = [self::ChildSrc, self::ScriptSrc, self::DefaultSrc];
return $map;
}
/**
* @return list<self>
*/
private static function reasonableMutationItems(): array
{
return [
self::ConnectSrc,
self::FontSrc,
self::FrameSrc,
self::ImgSrc,
self::MediaSrc,
self::ScriptSrcElem,
self::StyleSrcElem,
];
}
}
@@ -0,0 +1,191 @@
<?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 Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Page\ResourceHashCollection;
use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface;
use TYPO3\CMS\Core\SystemResource\Type\UriResource;
/**
* Per-request registry collecting CSP hash values for inline and static assets.
* Hash values are preferred over nonce values as they allow response caching.
*
* @internal
*/
#[Autoconfigure(public: true)]
final class DirectiveHashCollection implements \JsonSerializable
{
/**
* @var array{
* inline: array<string, list<HashValue>>,
* resource: array<string, list<HashValue>>,
* uri: array<string, list<HashValue>>,
* generic: array<string, list<HashValue>>
* }
*/
private array $hashValues = [
'inline' => [],
'resource' => [],
'uri' => [],
'generic' => [],
];
public function __construct(private readonly ResourceHashCollection $resourceHashCollection) {}
/**
* Computes a SHA-256 hash of the given inline content and stores it for the directive.
*/
public function addInlineHash(Directive $directive, string $content): void
{
$this->addHashValue($directive, HashValue::hash($content), 'inline');
}
/**
* Lazily computes a hash from a local file identified by an EXT: path or absolute path.
* No-op if the file cannot be read.
*/
public function addResourceHash(Directive $directive, string|UriInterface|StaticResourceInterface $resource): void
{
if (is_string($resource)) {
$resource = $this->resourceHashCollection->resolveResourceValue($resource);
if ($resource === null) {
return;
}
}
$hashValue = $this->resourceHashCollection->fetchResourceHash($resource);
if ($hashValue === null) {
return;
}
if ($resource instanceof UriInterface || $resource instanceof UriResource) {
$this->addHashValue($directive, $hashValue, 'uri');
} else {
$this->addHashValue($directive, $hashValue, 'resource');
}
}
/**
* Stores an already-computed HashValue (e.g. parsed from an `integrity` attribute).
*/
public function addGenericHashValue(Directive $directive, HashValue|string $hashValue): void
{
if (is_string($hashValue)) {
$hashValue = $this->convertHashValue($hashValue);
}
if ($hashValue !== null) {
$this->addHashValue($directive, $hashValue, 'generic');
}
}
/**
* Converts all stored hashes into MutationCollection instances that can be applied to the CSP.
* Hash values from all types are merged per directive.
*
* @return list<MutationCollection>
*/
public function asMutationCollections(): array
{
$byDirective = [];
foreach ($this->hashValues as $typeHashValues) {
foreach ($typeHashValues as $directiveName => $hashValues) {
$byDirective[$directiveName] = array_merge($byDirective[$directiveName] ?? [], $hashValues);
}
}
$collections = [];
foreach ($byDirective as $directiveName => $hashValues) {
$directive = Directive::from($directiveName);
// filter out duplicates
$stringHashValues = array_unique(array_map(strval(...), $hashValues));
$hashValues = array_map(HashValue::fromString(...), $stringHashValues);
// convert to CSP mutation
$mutations = array_map(
static fn(HashValue $hash): Mutation => new Mutation(MutationMode::Extend, $directive, $hash),
$hashValues
);
$collections[] = new MutationCollection(...$mutations);
}
return $collections;
}
public function isEmpty(): bool
{
foreach ($this->hashValues as $typeHashValues) {
if ($typeHashValues !== []) {
return false;
}
}
return true;
}
public function countInlineHashValues(?string $aspect = null): int
{
if ($aspect === null) {
return array_sum(
array_map(count(...), $this->hashValues['inline'])
);
}
return count($this->hashValues['inline'][$aspect] ?? []);
}
public function jsonSerialize(): array
{
$serialized = [];
foreach ($this->hashValues as $type => $typeHashValues) {
foreach ($typeHashValues as $directiveName => $hashValues) {
$serialized[$type][$directiveName] = array_map(
static fn(HashValue $hashValue): string => $hashValue->export(),
$hashValues
);
}
}
return $serialized;
}
/**
* Restores hash values from a previously serialized (cached) state.
*/
public function updateFromJson(array $data): void
{
foreach ($data as $type => $typeHashValues) {
foreach ($typeHashValues as $directiveName => $hashItems) {
$directive = Directive::from($directiveName);
foreach ($hashItems as $item) {
$this->addHashValue($directive, HashValue::fromString($item), $type);
}
}
}
}
private function addHashValue(Directive $directive, HashValue $hashValue, string $type): void
{
$this->hashValues[$type][$directive->value] ??= [];
$this->hashValues[$type][$directive->value][] = $hashValue;
}
private function convertHashValue(string $hashValue): ?HashValue
{
try {
return HashValue::fromString($hashValue);
} catch (\LogicException) {
// hash format not recognized, skip
return null;
}
}
}
@@ -0,0 +1,36 @@
<?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;
/**
* Representation of Content-Security-Policy disposition
* see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP#disposition
*/
enum Disposition: string
{
case enforce = 'enforce';
case report = 'report';
public function getHttpHeaderName(): string
{
return match ($this) {
self::enforce => 'Content-Security-Policy',
self::report => 'Content-Security-Policy-Report-Only',
};
}
}
@@ -0,0 +1,44 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report;
/**
* Event that is dispatched before persisting a new
* `\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report`.
*/
final class BeforePersistingReportEvent
{
/**
* @var Report|null Alternative report, or `null` to skip persistence
*/
public ?Report $report;
/**
* @param Report $originalReport The original report created by for the CSP violation
* @param ServerRequestInterface $request The HTTP POST request submitting the CSP violation
*/
public function __construct(
public readonly Report $originalReport,
public readonly ServerRequestInterface $request,
) {
$this->report = $originalReport;
}
}
@@ -0,0 +1,75 @@
<?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\Event;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report;
/**
* Event that is dispatched when reports are handled in the
* CSP backend module to find potential mutations as a resolution.
*/
final class InvestigateMutationsEvent
{
private bool $stopPropagation = false;
/**
* @var list<MutationSuggestion>
*/
private array $mutationSuggestions = [];
public function __construct(
public readonly Policy $policy,
public readonly Report $report,
) {}
public function isPropagationStopped(): bool
{
return $this->stopPropagation;
}
public function stopPropagation(): void
{
$this->stopPropagation = true;
}
/**
* @return list<MutationSuggestion>
*/
public function getMutationSuggestions(): array
{
return $this->mutationSuggestions;
}
/**
* Overrides all mutation suggestions (use carefully).
*/
public function setMutationSuggestions(MutationSuggestion ...$mutationSuggestions): void
{
$this->mutationSuggestions = $mutationSuggestions;
}
public function appendMutationSuggestions(MutationSuggestion ...$mutationSuggestions): void
{
if ($mutationSuggestions === []) {
return;
}
$this->mutationSuggestions += $mutationSuggestions;
}
}
@@ -0,0 +1,78 @@
<?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\Event;
use Psr\EventDispatcher\StoppableEventInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
final class PolicyMutatedEvent implements StoppableEventInterface
{
private bool $stopPropagation = false;
private Policy $currentPolicy;
/**
* @var list<MutationCollection>
*/
private array $mutationCollections;
public function __construct(
public readonly Scope $scope,
public readonly ?ServerRequestInterface $request,
public readonly Policy $defaultPolicy,
Policy $currentPolicy,
MutationCollection ...$mutationCollections
) {
$this->currentPolicy = $currentPolicy;
$this->mutationCollections = $mutationCollections;
}
public function isPropagationStopped(): bool
{
return $this->stopPropagation;
}
public function stopPropagation(): void
{
$this->stopPropagation = true;
}
public function getCurrentPolicy(): Policy
{
return $this->currentPolicy;
}
public function setCurrentPolicy(Policy $currentPolicy): void
{
$this->currentPolicy = $currentPolicy;
}
/**
* @return list<MutationCollection>
*/
public function getMutationCollections(): array
{
return $this->mutationCollections;
}
public function setMutationCollections(MutationCollection ...$mutationCollections): void
{
$this->mutationCollections = $mutationCollections;
}
}
@@ -0,0 +1,31 @@
<?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\Event;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag;
final readonly class PolicyPreparedEvent
{
public function __construct(
public PolicyBag $policyBag,
public ServerRequestInterface $request,
public string|ResponseInterface|null $response,
) {}
}
@@ -0,0 +1,264 @@
<?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 GuzzleHttp\Promise;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface;
use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Representation of Content-Security-Policy hash source, acting as proxy on
* files and URLs, to be resolved later when resource contents are actually compiled.
*/
final class HashProxy implements \JsonSerializable, SourceValueInterface
{
// one week
private const int CACHE_LIFETIME = 604800;
private HashType $type = HashType::sha256;
private ?string $glob = null;
/**
* @var list<string>|null system resource identifiers
*/
private ?array $resources = null;
/**
* @var list<string>|null
*/
private ?array $urls = null;
/**
* @param string $glob e.g. 'EXT:core/Tests/Unit/Security/ContentSecurityPolicy/Fixtures/*.js'
*/
public static function glob(string $glob): self
{
$pattern = GeneralUtility::getFileAbsFileName($glob);
$files = array_filter(glob($pattern), 'is_file');
if ($files === []) {
throw new \LogicException('Glob pattern did not resolve any files', 1678615628);
}
$target = new self();
$target->glob = $glob;
return $target;
}
/**
* @param string ...$resources system resource identifiers
* @see SystemResourceFactory
*/
public static function resource(string ...$resources): self
{
$target = new self();
$target->resources = $resources;
return $target;
}
public static function urls(string ...$urls): self
{
if ($urls === []) {
throw new \LogicException('No URL provided', 1678617132);
}
foreach ($urls as $url) {
if (!self::isValidUrl($url)) {
throw new \LogicException(
sprintf('Value "%s" is not a valid file-like URL', $url),
1678616641
);
}
}
$target = new self();
$target->urls = $urls;
return $target;
}
public static function knows(string $value): bool
{
return str_starts_with($value, "'hash-proxy-") && $value[-1] === "'";
}
public static function parse(string $value): self
{
if (!self::knows($value)) {
throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1678619052);
}
// extract from `'hash-proxy-[...]'`
$value = substr($value, 12, -1);
$properties = json_decode($value, true, 4, JSON_THROW_ON_ERROR);
if (!empty($properties['glob'])) {
$target = self::glob($properties['glob']);
} elseif (!empty($properties['resources'])) {
$target = self::resource(...$properties['resources']);
} elseif (!empty($properties['urls'])) {
$target = self::urls(...$properties['urls']);
} else {
throw new \LogicException('Cannot parse payload', 1678619395);
}
return $target->withType(HashType::from($properties['type'] ?? ''));
}
public function withType(HashType $type): self
{
if ($this->type === $type) {
return $this;
}
$target = clone $this;
$target->type = $type;
return $target;
}
public function isEmpty(): bool
{
return $this->glob === null && $this->resources === null && $this->urls === null;
}
public function compile(?FrontendInterface $cache = null): ?string
{
if ($this->isEmpty()) {
return null;
}
$hashes = array_map(
fn(string $hash): string => sprintf("'%s-%s'", $this->type->value, $hash),
$this->compileHashValues($cache)
);
return implode(' ', array_unique($hashes));
}
public function serialize(): ?string
{
if ($this->isEmpty()) {
return null;
}
return sprintf("'hash-proxy-%s'", json_encode($this, JSON_UNESCAPED_SLASHES));
}
public function jsonSerialize(): mixed
{
return array_filter([
'type' => $this->type,
'glob' => $this->glob,
'resources' => $this->resources,
'urls' => $this->urls,
]);
}
/**
* Checks whether the given value is a valid URI and has a file-like part
*/
private static function isValidUrl(string $value): bool
{
try {
$uri = new Uri($value);
} catch (\InvalidArgumentException) {
return false;
}
return basename($uri->getPath()) !== '';
}
private function compileHashValues(?FrontendInterface $cache): array
{
if ($this->glob !== null) {
$pattern = GeneralUtility::getFileAbsFileName($this->glob);
$files = array_filter(glob($pattern), 'is_file');
return array_map(
fn(string $file): string => base64_encode(
hash_file($this->type->value, $file, true)
),
$files
);
}
if ($this->resources !== null) {
$systemResourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class);
$resources = array_map(
static fn(string $resource): StaticResourceInterface => $systemResourceFactory->createResource($resource),
$this->resources
);
$resources = array_filter(
$resources,
static fn(StaticResourceInterface $resource): bool => $resource instanceof SystemResourceInterface
);
return array_map(
fn(SystemResourceInterface $resource): string => base64_encode(
hash($this->type->value, $resource->getContents(), true)
),
$resources
);
}
if ($this->urls !== null) {
$hashes = [];
$urls = $this->urls;
// try to resolve hashes from cache
if ($cache !== null) {
$urls = [];
$identifiers = [];
foreach ($this->urls as $url) {
$identifiers[$url] = 'CspHashProxyUrl_' . hash('xxh128', (json_encode([$this->type, $url])));
$cachedHash = $cache->get($identifiers[$url]);
if ($cachedHash === false) {
// fetch content of URL & generate hash
$urls[] = $url;
} elseif ($cachedHash !== null) {
// only use cached hash of URL that did not fail previously
$hashes[] = $cachedHash;
}
}
}
// process content of remaining URLs
$contents = $this->fetchUrlContents($urls);
foreach ($contents as $url => $content) {
$contentHash = $content !== null ? base64_encode(hash($this->type->value, $content, true)) : null;
if ($contentHash !== null) {
$hashes[] = $contentHash;
}
if ($cache !== null && isset($identifiers[$url])) {
$cache->set($identifiers[$url], $contentHash, ['CspHashProxyUrl'], self::CACHE_LIFETIME);
}
}
return $hashes;
}
return [];
}
/**
* @param list<string> $urls
* @return array<string, ?string> URL (key) and their response body contents of fulfilled requests (value)
*/
private function fetchUrlContents(array $urls): array
{
$client = GeneralUtility::makeInstance(GuzzleClientFactory::class)->getClient();
$promises = [];
foreach ($urls as $url) {
$promises[$url] = $client->requestAsync('GET', $url);
}
$resolvedPromises = Promise\Utils::settle($promises)->wait();
return array_map(
static function (array $response): ?string {
if ($response['state'] === 'fulfilled' && $response['value'] instanceof ResponseInterface) {
return (string)$response['value']->getBody();
}
return null;
},
$resolvedPromises
);
}
}
@@ -0,0 +1,54 @@
<?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;
/**
* Representation of Content-Security-Policy hash algorithm type
* see https://www.w3.org/TR/CSP3/#grammardef-hash-algorithm
*/
enum HashType: string
{
case sha256 = 'sha256';
case sha384 = 'sha384';
case sha512 = 'sha512';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* @return int length in bytes
*/
public function length(): int
{
return self::lengthMap()[$this];
}
private static function lengthMap(): \WeakMap
{
$map = new \WeakMap();
$map[self::sha256] = 32;
$map[self::sha384] = 48;
$map[self::sha512] = 64;
return $map;
}
}
@@ -0,0 +1,112 @@
<?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;
/**
* Representation of Content-Security-Policy hash source value
* see https://www.w3.org/TR/CSP3/#grammardef-hash-source
*/
final class HashValue implements \Stringable, SourceValueInterface
{
public readonly string $value;
public static function hash(string $payload, HashType $type = HashType::sha256): self
{
$value = hash($type->value, $payload, true);
return self::create($value, $type);
}
public static function create(string $value, HashType $type = HashType::sha256): self
{
return new self($value, $type);
}
/**
* @param string $value hash value (binary, hex or base64 encoded)
* @param HashType $type
*/
public function __construct(string $value, public readonly HashType $type = HashType::sha256)
{
$length = strlen($value);
if ($length === $this->type->length()) {
$value = base64_encode($value);
} elseif ($length === $this->type->length() * 2 && ctype_xdigit($value)) {
$value = base64_encode(hex2bin($value));
} elseif (strlen(base64_decode($value) ?: '') !== $this->type->length()) {
throw new \LogicException('Invalid base64 encoded value', 1678620881);
}
$this->value = $value;
}
public function __toString(): string
{
return sprintf("'%s-%s'", $this->type->value, $this->value);
}
/**
* Unquoted hash value, to be used like `integrity="sha256-..."`
*/
public function export(): string
{
return $this->type->value . '-' . $this->value;
}
public static function knows(string $value): bool
{
return preg_match(self::createParsingPattern(), $value) === 1;
}
public static function parse(string $value): self
{
if (preg_match(self::createParsingPattern(), $value, $matches) !== 1) {
throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1678621397);
}
return new self($matches['value'], HashType::from($matches['type']));
}
/**
* Parses the unquoted SRI format used in HTML `integrity` attributes (e.g. `sha256-abc123==`),
* as well as the quoted CSP format (e.g. `'sha256-abc123=='`).
*/
public static function fromString(string $value): self
{
$value = trim($value, "'");
$pattern = sprintf('/^(?P<type>%s)-(?P<value>.+)$/', implode('|', HashType::values()));
if (preg_match($pattern, $value, $matches) !== 1) {
throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1773012077);
}
return new self($matches['value'], HashType::from($matches['type']));
}
private static function createParsingPattern(): string
{
$types = array_map(static fn(HashType $type): string => $type->value, HashType::cases());
return sprintf("/^'(?P<type>%s)-(?P<value>.+)'$/", implode('|', $types));
}
public function compile(?FrontendInterface $cache = null): string
{
return (string)$this;
}
public function serialize(): string
{
return (string)$this;
}
}
@@ -0,0 +1,67 @@
<?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\Middleware;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\Behavior;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Type\Map;
/**
* @internal
*/
final class PolicyBag
{
private Map $policyMap;
public function __construct(
public readonly Scope $scope,
public readonly Map $dispositionMap,
public readonly Behavior $behavior,
public readonly ConsumableNonce $nonce,
public readonly DirectiveHashCollection $directiveHashCollection,
) {
$this->policyMap = new Map();
}
public function hasPolicies(): bool
{
return count($this->policyMap) !== 0;
}
public function hasPolicy(Disposition $disposition): bool
{
return isset($this->policyMap[$disposition]);
}
public function getPolicy(Disposition $disposition): Policy
{
return $this->policyMap[$disposition];
}
public function setPolicy(Disposition $disposition, Policy $policy): void
{
if (isset($this->policyMap[$disposition])) {
throw new \LogicException('Policy already set', 1646348401);
}
$this->policyMap[$disposition] = $policy;
}
}
@@ -0,0 +1,53 @@
<?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\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
/**
* @internal
*/
final readonly class ResponseService
{
public function __construct(private StreamFactoryInterface $streamFactory) {}
public function dropNonceFromHtmlResponse(ResponseInterface $response, ConsumableNonce $nonce): ResponseInterface
{
if (!str_starts_with($response->getHeaderLine('Content-Type'), 'text/html')) {
return $response;
}
$responseBody = $response->getBody();
if (!$responseBody->isReadable() || !$responseBody->isWritable() || $responseBody->getSize() === 0) {
return $response;
}
$stream = $this->streamFactory->createStream($this->dropNonceFromHtml((string)$responseBody, $nonce));
return $response->withBody($stream);
}
public function dropNonceFromHtml(string $html, ConsumableNonce $nonce): string
{
$noncePattern = preg_quote($nonce->value, '/');
return preg_replace(
'/\s*nonce="' . $noncePattern . '"|' . $noncePattern . '/',
'',
$html
);
}
}
@@ -0,0 +1,171 @@
<?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;
/**
* Helpers for working with Content-Security-Policy models.
*
* @internal
*/
readonly class ModelService
{
private const array SOURCE_PARSING_PRIORITIES = [
HashProxy::class => 50,
HashValue::class => 50,
];
/**
* @param ?FrontendInterface $cache to be used for storing compiled CSP aspects (disabled in install tool)
*/
public function __construct(private ?FrontendInterface $cache = null) {}
public function buildMutationSuggestionFromArray(array $array): MutationSuggestion
{
return new MutationSuggestion(
$this->buildMutationCollectionFromArray($array['collection'] ?? []),
(string)($array['identifier'] ?? ''),
isset($array['priority']) ? (int)$array['priority'] : null,
$array['label'] ?? null
);
}
public function buildMutationCollectionFromArray(array $array): MutationCollection
{
$mutations = array_map(
[$this, 'buildMutationFromArray'],
$array['mutations'] ?? []
);
return new MutationCollection(...$mutations);
}
public function buildMutationFromArray(array $array): Mutation
{
return new Mutation(
MutationMode::from($array['mode'] ?? ''),
Directive::from($array['directive'] ?? ''),
...$this->buildSourcesFromItems(...($array['sources'] ?? []))
);
}
public function buildSourcesFromItems(string ...$items): array
{
$sources = [];
foreach ($items as $item) {
$source = $this->buildSourceFromString($item);
if ($source === null) {
throw new \InvalidArgumentException(
sprintf('Could not convert source item "%s"', $item),
1677261214
);
}
$sources[] = $source;
}
return $sources;
}
public function buildSourceFromString(string $string): ?SourceInterface
{
if (str_starts_with($string, "'nonce-") && $string[-1] === "'") {
// use a proxy instead of a real Nonce instance
return SourceKeyword::nonceProxy;
}
try {
if ($string[0] === "'" && $string[-1] === "'") {
return SourceKeyword::from(substr($string, 1, -1));
}
if ($string[-1] === ':') {
return SourceScheme::from(substr($string, 0, -1));
}
return new UriValue($string);
} catch (\InvalidArgumentException|\ValueError) {
// no handling here
}
/** @var SourceValueInterface $sourceInterface */
foreach ($this->resolvePrioritizedSourceInterfaces() as $sourceInterface) {
if ($sourceInterface::knows($string)) {
return $sourceInterface::parse($string);
}
}
return new RawValue($string);
}
// @todo use SourceCollection instead?
public function serializeSources(SourceInterface ...$sources): array
{
$serialized = [];
foreach ($sources as $source) {
if ($source instanceof SourceKeyword && $source->vetoes()) {
$serialized = [];
}
$serialized[] = $this->serializeSource($source);
}
return $serialized;
}
public function compileSources(ConsumableNonce $nonce, SourceCollection $collection): array
{
$compiled = [];
foreach ($collection->sources as $source) {
if ($source instanceof SourceKeyword && $source->vetoes()) {
$compiled = [];
}
if ($source instanceof SourceValueInterface) {
$compiled[] = $source->compile($this->cache);
} else {
$compiled[] = $this->serializeSource($source, $nonce);
}
}
return array_filter($compiled);
}
/**
* @param ConsumableNonce|null $nonce used to substitute `SourceKeyword::nonceProxy` items during compilation
*/
public function serializeSource(SourceInterface $source, ?ConsumableNonce $nonce = null): string
{
if ($source === SourceKeyword::nonceProxy && $nonce !== null) {
return $nonce->count() > 0 ? "'nonce-" . $nonce->value . "'" : '';
}
if ($source instanceof SourceKeyword) {
return "'" . $source->value . "'";
}
if ($source instanceof SourceScheme) {
return $source->value . ':';
}
if ($source instanceof SourceValueInterface) {
return $source->serialize();
}
if ($source instanceof \Stringable) {
return (string)$source;
}
return '';
}
/**
* Resolves reverse sorted `SourceInterface` classes (higher priorities first).
* @return list<class-string<SourceValueInterface>>
*/
private function resolvePrioritizedSourceInterfaces(): array
{
$interfaces = self::SOURCE_PARSING_PRIORITIES;
arsort($interfaces);
return array_keys($interfaces);
}
}
@@ -0,0 +1,56 @@
<?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\Utility\GeneralUtility;
/**
* Representation of a Content-Security-Policy mutation, changing an existing policy directive.
*/
class Mutation implements \JsonSerializable
{
/**
* @var list<SourceInterface>
*/
public readonly array $sources;
public function __construct(
public readonly MutationMode $mode,
public readonly Directive $directive,
SourceInterface ...$sources,
) {
// @todo continue with source collecting internally?
if ($sources !== [] && $this->mode === MutationMode::Remove) {
throw new \LogicException(
'Cannot remove and declare sources at the same time',
1677244893
);
}
$this->sources = $sources;
}
public function jsonSerialize(): array
{
$service = GeneralUtility::makeInstance(ModelService::class);
return [
'mode' => $this->mode,
'directive' => $this->directive,
'sources' => $service->serializeSources(...$this->sources),
];
}
}
@@ -0,0 +1,41 @@
<?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;
/**
* A collection of mutations (sic!).
*/
final class MutationCollection implements \JsonSerializable
{
/**
* @var list<Mutation>
*/
public readonly array $mutations;
public function __construct(Mutation ...$mutations)
{
$this->mutations = $mutations;
}
public function jsonSerialize(): array
{
return [
'mutations' => $this->mutations,
];
}
}
@@ -0,0 +1,59 @@
<?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;
/**
* The mode used in mutations (sic!).
*/
enum MutationMode: string
{
/**
* sets (overrides) a directive completely
*/
case Set = 'set';
/**
* just appends to a given directive
*/
case Append = 'append';
/**
* inherits once from the corresponding ancestor chain
*/
case InheritOnce = 'inherit-once';
/**
* inherits again from the corresponding ancestor chain and merges existing sources
*/
case InheritAgain = 'inherit-again';
/**
* shortcut for `InheritOnce` and `Append`
*/
case Extend = 'extend';
/**
* reduces a directive by a given aspect
*/
case Reduce = 'reduce';
/**
* removes a directive completely
*/
case Remove = 'remove';
}
@@ -0,0 +1,30 @@
<?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;
/**
* Representation of a mutation origin, to keep track of resolutions
* to the Content-Security-Policy and how to revert again later.
*/
readonly class MutationOrigin
{
public function __construct(
public MutationOriginType $type,
public string $value
) {}
}
@@ -0,0 +1,25 @@
<?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;
enum MutationOriginType: string
{
case site = 'site';
case package = 'package';
case resolution = 'resolution';
}
@@ -0,0 +1,221 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\CspConfigurationFactory;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ResolutionRepository;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Map;
/**
* @internal
*/
final class MutationRepository
{
/**
* @var Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>>
*/
private ?Map $resolvedMutations;
/**
* @param Map<Scope, Map<MutationOrigin, MutationCollection>> $staticMutations
* (from DI, declared in `Configuration/ContentSecurityPolicies.php`)
*/
public function __construct(
#[Autowire(service: 'content.security.policies')]
private readonly Map $staticMutations,
private readonly SiteFinder $siteFinder,
private readonly ModelService $modelService,
private readonly ScopeRepository $scopeRepository,
private readonly ResolutionRepository $resolutionRepository,
private readonly CspConfigurationFactory $cspConfigurationFactory,
) {
$this->resolvedMutations = null;
}
/**
* @return Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>>
*/
public function findAll(): Map
{
if ($this->resolvedMutations === null) {
$this->resolveMutations();
}
return $this->resolvedMutations;
}
/**
* @return Map<MutationOrigin, MutationCollection>
*/
public function findByScope(Scope $scope, Disposition $disposition = Disposition::enforce): Map
{
if ($this->resolvedMutations === null) {
$this->resolveMutations();
}
$scope = $this->reduceScope($scope);
return $this->resolvedMutations[$scope][$disposition] ?? new Map();
}
private function resolveMutations(): void
{
if ($this->resolvedMutations !== null) {
return;
}
$this->resolvedMutations = new Map();
$allScopes = $this->scopeRepository->findAll();
// fetch resolutions from the database & assign them later to the resolved mutations map
$resolutions = new Map();
foreach ($this->resolutionRepository->findAll() as $resolution) {
// only for existing scopes (e.g. ignore scopes for sites, that are not existing anymore)
if (in_array($resolution->scope, $allScopes, true)) {
$mutationOrigin = new MutationOrigin(MutationOriginType::resolution, $resolution->summary);
$scopedTarget = $this->provideScopeInMap($resolution->scope, $resolutions);
$scopedTarget[$mutationOrigin] = $resolution->mutationCollection;
}
}
// assign generic backend and frontend scopes
foreach ([Scope::backend(), Scope::frontend()] as $scope) {
$scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations);
$dispositions = $scope === Scope::frontend()
? $this->cspConfigurationFactory->resolveFallbackDispositions()
: [Disposition::enforce];
foreach ($dispositions as $disposition) {
$disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget);
if (isset($this->staticMutations[$scope])) {
$disposedTarget->assign($this->staticMutations[$scope]);
}
if (isset($resolutions[$scope])) {
$disposedTarget->assign($resolutions[$scope]);
}
}
}
// fetch and assign site-specific mutations
foreach ($this->scopeRepository->findAllFrontendSites() as $scope) {
$site = $this->resolveSite($scope);
$scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations);
// fetch site-specific `enforce` and/or `report` disposition configuration
$dispositionMap = $this->cspConfigurationFactory->buildDispositionMap(
$site->getConfiguration()['contentSecurityPolicies'] ?? []
);
/**
* @var Disposition $disposition
* @var DispositionConfiguration $dispositionConfiguration
*/
foreach ($dispositionMap as $disposition => $dispositionConfiguration) {
$disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget);
$disposedTarget->assign($this->resolveStaticMutations($scope, $dispositionConfiguration));
if ($dispositionConfiguration->includeResolutions && isset($resolutions[$scope])) {
$disposedTarget->assign($resolutions[$scope]);
}
$mutationCollection = $this->resolveFrontendSiteMutationCollection($dispositionConfiguration);
if ($mutationCollection !== null) {
$mutationOrigin = new MutationOrigin(MutationOriginType::site, $scope->siteIdentifier);
$disposedTarget[$mutationOrigin] = $mutationCollection;
}
}
}
}
/**
* Resolves site-specific static mutations, applies `inheritDefault` configuration
* and filters generic static mutations based on the `packages` configuration.
*
* @return Map<MutationOrigin, MutationCollection>
*/
private function resolveStaticMutations(Scope $scope, DispositionConfiguration $dispositionConfiguration): Map
{
$target = new Map();
$scope = $this->reduceScope($scope);
if ($dispositionConfiguration->inheritDefault && isset($this->staticMutations[Scope::frontend()])) {
// mutations from `ContentSecurityPolicies.php` for generic frontend scope
$target->assign($this->staticMutations[Scope::frontend()]);
}
// mutations from `ContentSecurityPolicies.php` for a specific site identifier
if (isset($this->staticMutations[$scope])) {
$target->assign($this->staticMutations[$scope]);
}
// filter mutation origins by effective package names
$packageOrigins = array_filter(
$target->keys(),
static fn(MutationOrigin $origin) => $origin->type === MutationOriginType::package
);
$packageNames = array_map(static fn(MutationOrigin $origin) => $origin->value, $packageOrigins);
$effectivePackageNames = $dispositionConfiguration->resolveEffectivePackages(...$packageNames);
foreach ($packageOrigins as $mutationOrigin) {
if (!in_array($mutationOrigin->value, $effectivePackageNames, true)) {
unset($target[$mutationOrigin]);
}
}
return $target;
}
private function resolveFrontendSiteMutationCollection(DispositionConfiguration $dispositionConfiguration): ?MutationCollection
{
if ($dispositionConfiguration->mutations === []) {
return null;
}
$mutations = array_map(
fn(array $array) => $this->modelService->buildMutationFromArray($array),
$dispositionConfiguration->mutations
);
return new MutationCollection(...$mutations);
}
private function provideDispositionInMap(Disposition $disposition, Map $map): Map
{
if (!isset($map[$disposition])) {
$map[$disposition] = new Map();
}
return $map[$disposition];
}
/**
* @return Map<MutationOrigin, MutationCollection>
*/
private function provideScopeInMap(Scope $scope, Map $map): Map
{
$reducedScope = $this->reduceScope($scope);
if (!isset($map[$reducedScope])) {
$map[$reducedScope] = new Map();
}
return $map[$reducedScope];
}
/**
* Returns a reduce representation of the current object.
* In case a `Site` object was given, it will be reduced to just contain the site identifier.
*/
private function reduceScope(Scope $scope): Scope
{
if ($scope->isFrontendSite()) {
return Scope::frontendSiteIdentifier($scope->siteIdentifier);
}
return $scope;
}
private function resolveSite(Scope $scope): Site
{
return $scope->site ?? $this->siteFinder->getSiteByIdentifier($scope->siteIdentifier);
}
}
@@ -0,0 +1,82 @@
<?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\Crypto\HashService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Representation of a mutation suggested by a handler.
* The identifier is used to keep track of the original handling class/aspect.
* Higher priorities take precedence when being visualized in the backend module.
*/
final readonly class MutationSuggestion implements \JsonSerializable
{
/**
* @param string $identifier a unique identifier (e.g. `Vendor\Extension\MyHandler@knownJavaScript`)
* @param ?int $priority an integer in the range of [0; 10]
* @param ?string $label to be shown in backend module
*/
public function __construct(
public MutationCollection $collection,
public string $identifier,
public ?int $priority = null,
public ?string $label = null,
) {
if ($this->priority !== null && ($this->priority < 0 || $this->priority > 10)) {
throw new \LogicException('Priority must be in range [0; 10]', 1679601774);
}
if ($this->identifier === '') {
throw new \LogicException('Identifer cannot be empty', 1679601795);
}
}
public function hash(): string
{
return sha1(json_encode($this->getHashProperties()));
}
public function hmac(): string
{
$hashService = GeneralUtility::makeInstance(HashService::class);
return $hashService->hmac(json_encode($this->getHashProperties()), self::class);
}
public function jsonSerialize(): array
{
$properties = [
'collection' => $this->collection,
'identifier' => $this->identifier,
'priority' => $this->priority,
'label' => $this->label,
];
$hashService = GeneralUtility::makeInstance(HashService::class);
$hashContent = json_encode($this->getHashProperties());
$properties['hash'] = sha1($hashContent);
$properties['hmac'] = $hashService->hmac($hashContent, self::class);
return $properties;
}
private function getHashProperties(): array
{
return [
'collection' => $this->collection,
'identifier' => $this->identifier,
];
}
}
@@ -0,0 +1,368 @@
<?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);
}
}
@@ -0,0 +1,183 @@
<?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 Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Core\RequestId;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Middleware\AbstractContentSecurityPolicyReporter;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyPreparedEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Provide a Content-Security-Policy representation for a given scope (e.g. backend, frontend, frontend.my-site).
*
* @internal
*/
#[Autoconfigure(public: true)]
final readonly class PolicyProvider
{
private const string REPORTING_URI = '@http-reporting';
public function __construct(
private RequestId $requestId,
private SiteFinder $siteFinder,
private PolicyRegistry $policyRegistry,
private EventDispatcherInterface $eventDispatcher,
private MutationRepository $mutationRepository,
private BackendEntryPointResolver $backendEntryPointResolver,
private HashService $hashService,
) {}
public function prepare(
PolicyBag $policyBag,
ServerRequestInterface $request,
string|ResponseInterface|null $response,
): void {
foreach ($policyBag->dispositionMap as $disposition => $configuration) {
if ($policyBag->hasPolicy($disposition)) {
continue;
}
$policy = $this->provideFor($policyBag->scope, $disposition, $request);
if (!$policy->isEmpty()) {
$reportingUrl = $this->getReportingUrlFor(
$policyBag->scope,
$request,
$policyBag->dispositionMap[$disposition]
);
if ($reportingUrl !== null) {
$policy = $policy->report(UriValue::fromUri($reportingUrl));
}
}
$policyBag->setPolicy($disposition, $policy);
}
$this->eventDispatcher->dispatch(
new PolicyPreparedEvent($policyBag, $request, $response)
);
}
/**
* Provides the complete, dynamically mutated policy to be used in HTTP responses.
*/
public function provideFor(
Scope $scope,
Disposition $disposition = Disposition::enforce,
?ServerRequestInterface $request = null,
): Policy {
// @todo add policy cache per scope
$defaultPolicy = new Policy();
$mutationCollections = iterator_to_array(
$this->mutationRepository->findByScope($scope, $disposition),
false
);
// add temporary(!) mutations that were collected during processing this request
if ($this->policyRegistry->hasMutationCollections()) {
$mutationCollections = array_merge(
$mutationCollections,
$this->policyRegistry->getMutationCollections()
);
}
// apply all mutations to current policy
$currentPolicy = $defaultPolicy->mutate(...$mutationCollections);
// allow other components to modify the current policy individually via PSR-14 event
$event = new PolicyMutatedEvent($scope, $request, $defaultPolicy, $currentPolicy, ...$mutationCollections);
$this->eventDispatcher->dispatch($event);
return $event->getCurrentPolicy();
}
public function getReportingUrlFor(
Scope $scope,
ServerRequestInterface $request,
?DispositionConfiguration $dispositionConfiguration = null,
): ?UriInterface {
$value = $dispositionConfiguration->reportingUrl
?? DispositionConfiguration::normalizeReportingUrl(
$GLOBALS['TYPO3_CONF_VARS'][$scope->type->abbreviate()]['contentSecurityPolicyReportingUrl'] ?? null
);
// using the local reporting URI is explicitly disabled
if ($value === false) {
return null;
}
if (is_string($value) && $value !== '') {
try {
return new Uri($value);
} catch (\InvalidArgumentException) {
return null;
}
}
$requestTime = (string)$this->requestId->microtime;
$requestHash = $this->hashService->hmac($requestTime, AbstractContentSecurityPolicyReporter::class);
$uriBase = $this->getDefaultReportingUriBase($scope, $request);
return $uriBase->withQuery(
$uriBase->getQuery() . '&requestTime=' . $requestTime . '&requestHash=' . $requestHash
);
}
/**
* Returns the URI base, for better partitioning it should be extended by `&requestTime=...`
*/
public function getDefaultReportingUriBase(Scope $scope, ServerRequestInterface $request, bool $absolute = true): UriInterface
{
$normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
// resolve URI from current site language or site default language in frontend scope
if ($scope->isFrontendSite()) {
$site = $this->resolveSite($scope);
$siteLanguage = $request->getAttribute('siteLanguage');
$siteLanguage = $siteLanguage instanceof SiteLanguage ? $siteLanguage : $site->getDefaultLanguage();
$uri = $siteLanguage->getBase();
$uri = $uri->withPath(rtrim($uri->getPath(), '/') . '/');
// otherwise fall back to current request URI
} else {
$uri = new Uri($normalizedParams->getSitePath());
}
// add backend entryPoint route prefix in backend scope
if ($scope->type->isBackend()) {
$uri = $this->backendEntryPointResolver->getUriFromRequest($request);
}
// prefix current require scheme, host, port in case it's not given
if ($absolute && ($uri->getScheme() === '' || $uri->getHost() === '')) {
$current = new Uri($normalizedParams->getSiteUrl());
$uri = $uri
->withScheme($current->getScheme())
->withHost($current->getHost())
->withPort($current->getPort());
} elseif (!$absolute && ($uri->getScheme() !== '' || $uri->getHost() !== '')) {
$uri = $uri->withScheme('')->withHost('')->withPort(null);
}
// `/en/@http-reporting?csp=report` (relative)
// `https://ip12.anyhost.it:8443/en/@http-reporting?csp=report` (absolute)
return $uri->withPath($uri->getPath() . self::REPORTING_URI)->withQuery('csp=report');
}
private function resolveSite(Scope $scope): Site
{
return $scope->site ?? $this->siteFinder->getSiteByIdentifier($scope->siteIdentifier);
}
}
@@ -0,0 +1,59 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/**
* A shared service registry to hold additional adjustments that were collected during
* processing the current request. For instance, it would be used to temporarily(!) allow
* a particular CSP URL/aspect.
*
* @internal
*/
#[Autoconfigure(public: true)]
final class PolicyRegistry
{
/**
* @var list<MutationCollection>
*/
private array $mutationCollections = [];
public function appendMutationCollection(MutationCollection $collection): void
{
$this->mutationCollections[] = $collection;
}
/**
* @return list<MutationCollection>
*/
public function getMutationCollections(): array
{
return $this->mutationCollections;
}
public function setMutationsCollections(MutationCollection ...$collections): void
{
$this->mutationCollections = $collections;
}
public function hasMutationCollections(): bool
{
return $this->mutationCollections !== [];
}
}
@@ -0,0 +1,114 @@
<?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\Processing;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue;
/**
* Suggest potential resolutions for simple asset violations, e.g.
* in case https://example.org/file.js could was blocked to be loaded,
* it would exactly suggest this mutation for the given directive.
*/
class AssetHandler
{
use HandlerTrait;
#[AsEventListener('security-csp-asset-handler')]
public function __invoke(InvestigateMutationsEvent $event): void
{
// skip, in case there are mutations already
if ($event->getMutationSuggestions() !== []) {
return;
}
$effectiveDirective = $this->resolveEffectiveDirective($event->report);
$blockedUri = $this->resolveBlockedUri($event->report);
// skip in case `blocked-uri` is not actually a URI with hostname,
// or directive is not reasonable to be mutated at all
if ($effectiveDirective === null
|| $blockedUri === null
|| $blockedUri->getHost() === ''
|| !$effectiveDirective->isMutationReasonable()) {
return;
}
$event->appendMutationSuggestions(
...$this->createSuggestions($effectiveDirective, $blockedUri)
);
}
/**
* @return list<MutationSuggestion>
*/
private function createSuggestions(Directive $effectiveDirective, UriInterface $blockedUri): array
{
// @todo resolve URLs to current scope to 'self' instead of using the URL
$suggestions = [];
$hostUri = $blockedUri->withUserInfo('')->withQuery('')->withFragment('');
// resolves 'https://example.org/'
if ($hostUri->getScheme() !== '') {
$suggestions[] = new MutationSuggestion(
$this->createExtendingMutationCollection(
$effectiveDirective,
UriValue::fromUri($hostUri->withPath(''))
),
self::class . '@hostWithScheme',
3,
'Assets from host'
);
}
// resolves 'https://example.org/path/to/resource.js'
if ($hostUri->getScheme() !== '' && $hostUri->getPath() !== '') {
$suggestions[] = new MutationSuggestion(
$this->createExtendingMutationCollection(
$effectiveDirective,
UriValue::fromUri($hostUri)
),
self::class . '@completeUrl',
2,
'Asset from specific URL'
);
}
// resolves '*.example.org'
$suggestions[] = new MutationSuggestion(
$this->createExtendingMutationCollection(
$effectiveDirective,
new UriValue('*.' . $hostUri->getHost())
),
self::class . '@wildcardHost',
1,
'Asset from wildcard host'
);
return $suggestions;
}
private function createExtendingMutationCollection(Directive $effectiveDirective, UriValue $value): MutationCollection
{
return new MutationCollection(
new Mutation(MutationMode::Extend, $effectiveDirective, $value)
);
}
}
@@ -0,0 +1,149 @@
<?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\Processing;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue;
/**
* Suggests resolutions for Google-specific assets (e.g. Google Maps JS API).
*/
class GoogleMapsHandler
{
use HandlerTrait;
private const array DOMAIN_NAMES = [
'fonts.googleapis.com',
'maps.googleapis.com',
'fonts.gstatic.com',
'maps.gstatic.com',
];
private static MutationSuggestion $suggestion;
private static Policy $policyNarrative;
public function __construct()
{
if (!isset(self::$suggestion)) {
self::$suggestion = $this->createGoogleMapsSuggestion();
self::$policyNarrative = (new Policy())->mutate(self::$suggestion->collection);
}
}
#[AsEventListener('security-csp-google-maps-handler')]
public function __invoke(InvestigateMutationsEvent $event): void
{
$effectiveDirective = $this->resolveEffectiveDirective($event->report);
$blockedUri = $this->resolveBlockedUri($event->report);
if ($effectiveDirective === null || $blockedUri === null || !$this->isInDomainNames($blockedUri->getHost())) {
return;
}
// skip other handlers
$event->stopPropagation();
// clear mutations in case a resolution is contained (without inference) in current policy already
if ($event->policy->contains(self::$policyNarrative)) {
$event->setMutationSuggestions();
return;
}
// otherwise create mutations for Google Maps JS API,
// in case the policy narrative would cover (with inference) the current violation
if (self::$policyNarrative->coversDirective($effectiveDirective, UriValue::fromUri($blockedUri))) {
// override existing mutations (this handler seems to be more specific)
$event->setMutationSuggestions(self::$suggestion);
}
}
private function createGoogleMapsSuggestion(): MutationSuggestion
{
// see https://developers.google.com/maps/documentation/javascript/content-security-policy
// @todo `Note that 'strict-dynamic' is present, so host-based allowlisting is disabled.`
$collection = new MutationCollection(
new Mutation(
MutationMode::Extend,
Directive::ScriptSrcElem,
SourceKeyword::strictDynamic, // requires(!) Nonce everywhere
SourceScheme::https, // thx Google!
SourceKeyword::unsafeEval, // thx Google!
SourceScheme::blob, // thx Google!
),
new Mutation(
MutationMode::Extend,
Directive::ImgSrc,
// @todo should be UriValue (which currently does not support scheme wildcards)
new UriValue('https://*.googleapis.com'),
new UriValue('https://*.gstatic.com'),
new UriValue('*.google.com'),
new UriValue('*.googleusercontent.com'),
),
new Mutation(
MutationMode::Extend,
Directive::FrameSrc,
new UriValue('*.google.com'),
),
new Mutation(
MutationMode::Extend,
Directive::ConnectSrc,
new UriValue('*.google.com'),
new UriValue('https://*.googleapis.com'),
new UriValue('https://*.gstatic.com'),
SourceScheme::blob, // thx Google!
SourceScheme::data, // thx Google!
),
new Mutation(
MutationMode::Extend,
Directive::FontSrc,
new UriValue('https://fonts.gstatic.com'),
),
new Mutation(
MutationMode::Extend,
Directive::StyleSrcElem,
SourceKeyword::nonceProxy,
new UriValue('https://fonts.gstatic.com'),
new UriValue('https://fonts.googleapis.com'),
),
new Mutation(
MutationMode::Extend,
Directive::WorkerSrc,
SourceScheme::blob,
),
);
return new MutationSuggestion($collection, self::class, 5, 'Google Maps');
}
private function isInDomainNames(string $hostName): bool
{
if (in_array($hostName, self::DOMAIN_NAMES, true)) {
return true;
}
foreach (self::DOMAIN_NAMES as $domainName) {
if (str_ends_with($hostName, '.' . $domainName)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,44 @@
<?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\Processing;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report;
trait HandlerTrait
{
private function resolveBlockedUri(Report $report): ?UriInterface
{
try {
return new Uri($report?->details['blocked-uri'] ?? '');
} catch (\InvalidArgumentException) {
return null;
}
}
/**
* `violatedDirective` is a historical alias of `effectiveDirective`
* see https://www.w3.org/TR/CSP3/#violation-events
*/
private function resolveEffectiveDirective(Report $report): ?Directive
{
return Directive::tryFrom($report?->details['effective-directive'] ?? '');
}
}
@@ -0,0 +1,34 @@
<?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;
/**
* Representation of a plain, raw string value that does not have
* a particular meaning in the terms of Content-Security-Policy.
*
* @internal Might be changed or removed at a later time
*/
readonly class RawValue implements \Stringable, SourceInterface
{
public function __construct(public string $value) {}
public function __toString(): string
{
return $this->value;
}
}
@@ -0,0 +1,95 @@
<?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\Reporting;
use Symfony\Component\Uid\UuidV4;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
/**
* @internal
*/
class Report implements \JsonSerializable
{
public readonly UuidV4 $uuid;
public readonly \DateTimeImmutable $created;
public readonly \DateTimeImmutable $changed;
public static function fromArray(array $array): static
{
$meta = json_decode($array['meta'] ?? '', true, 16, JSON_THROW_ON_ERROR);
$details = json_decode($array['details'] ?? '', true, 16, JSON_THROW_ON_ERROR);
return new static(
Scope::from($array['scope'] ?? ''),
ReportStatus::from($array['status'] ?? 0),
$array['request_time'] ?? 0,
$meta ?: [],
new ReportDetails($details ?: []),
$array['summary'] ?? '',
UuidV4::fromString($array['uuid'] ?? ''),
DateTimeFactory::createFromTimestamp((int)($array['created'] ?? 0)),
DateTimeFactory::createFromTimestamp((int)($array['changed'] ?? 0)),
);
}
final public function __construct(
public readonly Scope $scope,
public readonly ReportStatus $status,
public readonly int $requestTime,
public readonly array $meta,
public readonly ReportDetails $details,
public readonly string $summary = '',
?UuidV4 $uuid = null,
?\DateTimeImmutable $created = null,
?\DateTimeImmutable $changed = null,
) {
$this->uuid = $uuid ?? new UuidV4();
$this->created = $created ?? new \DateTimeImmutable();
$this->changed = $changed ?? $this->created;
}
public function jsonSerialize(): array
{
return [
'uuid' => $this->uuid,
'status' => $this->status->value,
'created' => $this->created->format(\DateTimeInterface::ATOM),
'changed' => $this->changed->format(\DateTimeInterface::ATOM),
'scope' => $this->scope,
'request_time' => $this->requestTime,
'meta' => $this->meta,
'details' => $this->details,
'summary' => $this->summary,
];
}
public function toArray(): array
{
return [
'uuid' => (string)$this->uuid,
'status' => $this->status->value,
'created' => $this->created->getTimestamp(),
'changed' => $this->changed->getTimestamp(),
'scope' => (string)$this->scope,
'request_time' => $this->requestTime,
'meta' => json_encode($this->meta),
'details' => json_encode($this->details->getArrayCopy()),
'summary' => $this->summary,
];
}
}
@@ -0,0 +1,28 @@
<?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\Reporting;
/**
* @internal
*/
enum ReportAttribute: string
{
case fixable = 'fixable';
case irrelevant = 'irrelevant';
case suspicious = 'suspicious';
}
@@ -0,0 +1,49 @@
<?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\Reporting;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Demand DTO for querying Report entities from the corresponding repository.
* @internal
*/
class ReportDemand
{
public ?ReportStatus $status = ReportStatus::New;
public ?Scope $scope = null;
public ?array $summaries = null;
public ?int $requestTime = null;
public bool $afterRequestTime = false;
public ?string $orderFieldName = 'created';
public ?string $orderDirection = 'desc';
public static function create(): self
{
return GeneralUtility::makeInstance(self::class);
}
public static function forSummaries(array $summaries): self
{
$target = self::create();
$target->status = null;
$target->summaries = $summaries;
return $target;
}
}
@@ -0,0 +1,53 @@
<?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\Reporting;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition;
/**
* @internal
*/
class ReportDetails extends \ArrayObject implements \JsonSerializable
{
public function __construct(array $array)
{
if (!empty($array['violated-directive']) && !isset($array['effective-directive'])) {
$array['effective-directive'] = $array['violated-directive'];
}
parent::__construct($array);
}
public function jsonSerialize(): array
{
$details = $this->getArrayCopy();
return array_combine(
array_map(self::toCamelCase(...), array_keys($details)),
array_values($details)
);
}
public function resolveDisposition(): Disposition
{
return Disposition::tryFrom($this['disposition'] ?? '') ?? Disposition::enforce;
}
protected static function toCamelCase(string $value): string
{
return lcfirst(str_replace('-', '', ucwords($value, '-')));
}
}
@@ -0,0 +1,331 @@
<?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\Reporting;
use Doctrine\DBAL\ArrayParameterType;
use Symfony\Component\Uid\UuidV4;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
/**
* @internal
*/
readonly class ReportRepository
{
protected const TABLE_NAME = 'sys_http_report';
protected const TYPE = 'csp-report';
public function __construct(protected ConnectionPool $pool) {}
/**
* @return list<Report>
*/
public function findAll(?ReportDemand $demand = null): array
{
$demand ??= ReportDemand::create();
$result = $this->prepareQueryBuilder($demand)
->select('*')
->executeQuery();
return array_map(
static fn(array $row) => Report::fromArray($row),
$result->fetchAllAssociative()
);
}
/**
* @return list<SummarizedReport>
*/
public function findAllSummarized(?ReportDemand $demand = null): array
{
$demand ??= ReportDemand::create();
$queryBuilder = $this->prepareQueryBuilder($demand, 'report');
$uuidQueryBuilder = $this->getQueryBuilder()->from(self::TABLE_NAME, 'tab_uuid');
$summaryQueryBuilder = $this->getQueryBuilder()->from(self::TABLE_NAME, 'tab_summary');
$expr = $queryBuilder->expr();
// these nested query builders are doing a bunch of things to meet `ONLY_FULL_GROUP_BY` constraints
// + inner "summary" builder: build [summary; created] relation, summary must be distinct
// + helping "uuid" builder: build [uuid <= {summary; created}] relation, summary must be distinct
// + outer "report" builder: finally query [* <= {uuid <= {summary; created}}],
// conditions/filters are applied to this effective outer query builder
$summaryQueryBuilder
->selectLiteral($this->createFunctionLiteral(
$queryBuilder,
'MAX',
'tab_summary.created',
'created'
))
->addSelectLiteral('summary')
->groupBy('summary');
$this->applySummaryJoin(
$uuidQueryBuilder,
'tab_uuid',
$summaryQueryBuilder->getSQL(),
'res_summary',
(string)$expr->and(
$expr->eq('tab_uuid.summary', 'res_summary.summary'),
$expr->eq('tab_uuid.created', 'res_summary.created')
)
);
$uuidQueryBuilder
->selectLiteral($this->createFunctionLiteral(
$queryBuilder,
// using `MAX(col)` since `ANY_VALUE(col)` is not supported by PostgreSQL
'MAX',
'tab_uuid.uuid',
'uuid'
))
->groupBy('tab_uuid.summary');
$this->applySummaryJoin(
$queryBuilder,
'report',
$uuidQueryBuilder->getSQL(),
'res_uuid',
$expr->eq('report.uuid', 'res_uuid.uuid')
);
$result = $queryBuilder
->select('report.*')
->executeQuery();
$summaryCountMap = $this->fetchSummaryCountMap();
return array_map(
static fn(array $row) => SummarizedReport::fromArray($row)
->withCount($summaryCountMap[$row['summary']] ?? 0),
$result->fetchAllAssociative()
);
}
public function findByUuid(UuidV4 $uuid): ?Report
{
$result = $this->getConnection()->select(
['*'],
self::TABLE_NAME,
['uuid' => (string)$uuid]
);
$row = $result->fetchAssociative();
if (empty($row)) {
return null;
}
return Report::fromArray($row);
}
/**
* @return list<Report>
*/
public function findBySummary(string ...$summaries): array
{
if ($summaries === []) {
return [];
}
$demand = ReportDemand::forSummaries($summaries);
$result = $this->prepareQueryBuilder($demand)
->select('*')
->executeQuery();
return array_map(
static fn(array $row) => SummarizedReport::fromArray($row),
$result->fetchAllAssociative()
);
}
public function add(Report $report): bool
{
return $this->getConnection()->insert(
self::TABLE_NAME,
array_merge(
$report->toArray(),
['type' => self::TYPE]
)
) === 1;
}
public function updateStatus(ReportStatus $status, UuidV4 ...$uuids): int
{
$queryBuilder = $this->getQueryBuilder();
return $queryBuilder
->update(self::TABLE_NAME)
->set('status', $status->value)
->set('changed', time())
->where(
$queryBuilder->expr()->in(
'uuid',
$queryBuilder->createNamedParameter($uuids, ArrayParameterType::STRING)
)
)
->executeStatement();
}
public function remove(UuidV4 $uuid): bool
{
return $this->getConnection()->delete(
self::TABLE_NAME,
['uuid' => (string)$uuid]
) === 1;
}
public function removeAll(?Scope $scope = null): int
{
if ($scope === null) {
return $this->getConnection()->truncate(self::TABLE_NAME);
}
return $this->getConnection()->delete(self::TABLE_NAME, ['scope' => (string)$scope]);
}
/**
* @return array<string, int>
*/
protected function fetchSummaryCountMap(): array
{
$queryBuilder = $this->getQueryBuilder();
$rows = $queryBuilder
->select('summary')
->addSelectLiteral(sprintf(
'COUNT(%s) AS %s',
$queryBuilder->quoteIdentifier('summary'),
$queryBuilder->quoteIdentifier('summary_count')
))
->from(self::TABLE_NAME)
->groupBy('summary')
->executeQuery()
->fetchAllAssociative();
return array_combine(
array_column($rows, 'summary'),
array_column($rows, 'summary_count'),
);
}
protected function prepareQueryBuilder(ReportDemand $demand, ?string $alias = null): QueryBuilder
{
$queryBuilder = $this->getQueryBuilder();
$queryBuilder->from(self::TABLE_NAME, $alias);
$this->applyStaticTypeCondition($queryBuilder, $alias);
$this->applyDemand($demand, $queryBuilder, $alias);
return $queryBuilder;
}
protected function applyDemand(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void
{
$this->applyDemandConditions($demand, $queryBuilder, $alias);
$this->applyDemandSorting($demand, $queryBuilder, $alias);
}
protected function applyDemandConditions(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void
{
$expr = $queryBuilder->expr();
$aliasPrefix = $this->prepareAliasPrefix($alias);
if ($demand->status !== null) {
$queryBuilder->andWhere($expr->eq(
$aliasPrefix . 'status',
$queryBuilder->createNamedParameter($demand->status->value, Connection::PARAM_INT)
));
}
if ($demand->scope !== null) {
$queryBuilder->andWhere($expr->eq(
$aliasPrefix . 'scope',
$queryBuilder->createNamedParameter((string)$demand->scope)
));
}
if ($demand->summaries !== null) {
$queryBuilder->andWhere($expr->in(
$aliasPrefix . 'summary',
$queryBuilder->createNamedParameter(
$demand->summaries,
ArrayParameterType::STRING
),
));
}
if ($demand->requestTime !== null) {
$requestTimeParam = $queryBuilder->createNamedParameter(
$demand->requestTime,
Connection::PARAM_INT
);
if ($demand->afterRequestTime) {
$queryBuilder->andWhere($expr->gt($aliasPrefix . 'request_time', $requestTimeParam));
} else {
$queryBuilder->andWhere($expr->eq($aliasPrefix . 'request_time', $requestTimeParam));
}
}
}
protected function applyDemandSorting(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void
{
$aliasPrefix = $this->prepareAliasPrefix($alias);
if ($demand->orderFieldName !== null && $demand->orderDirection !== null) {
$queryBuilder->orderBy(
$aliasPrefix . $demand->orderFieldName,
$demand->orderDirection
);
}
}
protected function applyStaticTypeCondition(QueryBuilder $queryBuilder, ?string $alias = null): void
{
$aliasPrefix = $this->prepareAliasPrefix($alias);
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
$aliasPrefix . 'type',
$queryBuilder->createNamedParameter(self::TYPE)
)
);
}
protected function applySummaryJoin(QueryBuilder $queryBuilder, string $fromAlias, string $join, string $alias, string $condition): void
{
$queryBuilder->getConcreteQueryBuilder()->join(
$queryBuilder->quoteIdentifier($fromAlias),
sprintf('(%s)', $join),
$queryBuilder->quoteIdentifier($alias),
$condition
);
}
protected function createFunctionLiteral(QueryBuilder $queryBuilder, string $functionName, string $fieldName, ?string $alias = null): string
{
$values = [
$functionName,
$queryBuilder->quoteIdentifier($fieldName),
];
if ($alias === null) {
$format = '%s(%s)';
} else {
$format = '%s(%s) AS %s';
$values[] = $queryBuilder->quoteIdentifier($alias);
}
return vsprintf($format, $values);
}
protected function prepareAliasPrefix(?string $alias = null): string
{
return $alias === null ? '' : $alias . '.';
}
protected function getQueryBuilder(): QueryBuilder
{
return $this->pool->getQueryBuilderForTable(self::TABLE_NAME);
}
protected function getConnection(): Connection
{
return $this->pool->getConnectionForTable(self::TABLE_NAME);
}
}
@@ -0,0 +1,29 @@
<?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\Reporting;
/**
* @internal
*/
enum ReportStatus: int
{
case New = 0;
case Handled = 1;
case Muted = 2;
case Deleted = 9;
}
@@ -0,0 +1,91 @@
<?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\Reporting;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ModelService;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal
*/
class Resolution implements \JsonSerializable
{
public readonly \DateTimeImmutable $created;
public static function fromArray(array $array): static
{
if (!isset($array['summary'])) {
throw new \LogicException('Summary must be given', 1677263951);
}
$service = GeneralUtility::makeInstance(ModelService::class);
$mutationCollection = $array['mutation_collection'] ?? null;
if (is_string($mutationCollection)) {
$mutationCollection = json_decode($mutationCollection, true, 16, JSON_THROW_ON_ERROR);
}
$mutationCollection = $service->buildMutationCollectionFromArray(
is_array($mutationCollection) ? $mutationCollection : []
);
$meta = json_decode($array['meta'] ?? '', true, 16, JSON_THROW_ON_ERROR);
return new static(
$array['summary'],
Scope::from($array['scope'] ?? ''),
$array['mutation_identifier'],
$mutationCollection,
$meta ?: [],
DateTimeFactory::createFromTimestamp((int)($array['created'] ?? 0)),
);
}
final public function __construct(
public readonly string $summary,
public readonly Scope $scope,
public readonly string $mutationIdentifier,
public readonly MutationCollection $mutationCollection,
public readonly array $meta = [],
?\DateTimeImmutable $created = null,
) {
$this->created = $created ?? new \DateTimeImmutable();
}
public function jsonSerialize(): array
{
return [
'summary' => $this->summary,
'created' => $this->created->format(\DateTimeInterface::ATOM),
'scope' => $this->scope,
'mutationIdentifier' => $this->mutationIdentifier,
'mutationCollection' => $this->mutationCollection,
'meta' => $this->meta,
];
}
public function toArray(): array
{
return [
'summary' => $this->summary,
'created' => $this->created->getTimestamp(),
'scope' => (string)$this->scope,
'mutation_identifier' => $this->mutationIdentifier,
'mutation_collection' => json_encode($this->mutationCollection),
'meta' => json_encode($this->meta),
];
}
}
@@ -0,0 +1,146 @@
<?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\Reporting;
use Doctrine\DBAL\Exception\TableNotFoundException;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
/**
* @internal
*/
readonly class ResolutionRepository
{
protected const TABLE_NAME = 'sys_csp_resolution';
public function __construct(protected ConnectionPool $pool) {}
/**
* @return list<Resolution>
*/
public function findAll(): array
{
$result = $this->getConnection()->select(
['*'],
self::TABLE_NAME,
[],
[],
['created' => 'asc']
);
return array_map(
static fn(array $row) => Resolution::fromArray($row),
$result->fetchAllAssociative()
);
}
/**
* @return list<Resolution>
*/
public function findByScope(Scope $scope): array
{
try {
$result = $this->getConnection()->select(
['*'],
self::TABLE_NAME,
['scope' => (string)$scope],
[],
['created' => 'asc']
);
} catch (TableNotFoundException) {
// We usually don't take care of non-existing table throughout the system.
// This one however can happen when major upgrading TYPO3 and calling the
// backend first time. It is fair to catch this case to prevent forcing admins
// to unlock standalone install tool or to use cli to fix db schema.
return [];
}
return array_map(
static fn(array $row) => Resolution::fromArray($row),
$result->fetchAllAssociative()
);
}
public function findBySummary(string $summary): ?Resolution
{
if ($summary === '') {
return null;
}
$result = $this->getConnection()->select(
['*'],
self::TABLE_NAME,
['summary' => $summary]
);
$row = $result->fetchAssociative();
if (empty($row)) {
return null;
}
return Resolution::fromArray($row);
}
/**
* @return list<Resolution>
*/
public function findByIdentifier(string $identifier, bool $prefix = false): array
{
if ($identifier === '') {
return [];
}
if ($prefix) {
$queryBuilder = $this->pool->getQueryBuilderForTable(self::TABLE_NAME);
$result = $queryBuilder
->select('*')
->from(self::TABLE_NAME)
->where($queryBuilder->expr()->like(
'mutation_identifier',
$queryBuilder->createNamedParameter($queryBuilder->escapeLikeWildcards($identifier) . '%')
))
->executeQuery();
} else {
$result = $this->getConnection()->select(
['*'],
self::TABLE_NAME,
['mutation_identifier' => $identifier]
);
}
return array_map(
static fn(array $row) => Resolution::fromArray($row),
$result->fetchAllAssociative()
);
}
public function add(Resolution $resolution): bool
{
return $this->getConnection()->insert(
self::TABLE_NAME,
$resolution->toArray()
) === 1;
}
public function remove(string $summary): bool
{
return $this->getConnection()->delete(
self::TABLE_NAME,
['summary' => $summary]
) === 1;
}
protected function getConnection(): Connection
{
return $this->pool->getConnectionForTable(self::TABLE_NAME);
}
}
@@ -0,0 +1,71 @@
<?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\Reporting;
/**
* @internal
*/
class SummarizedReport extends Report
{
protected int $count = 0;
/**
* @var list<ReportAttribute>
*/
protected array $attributes = [];
/**
* @var list<string>
*/
protected array $mutationHashes = [];
public function withCount(int $count): self
{
$target = clone $this;
$target->count = $count;
return $target;
}
public function withAttribute(ReportAttribute $attribute): self
{
if (in_array($attribute, $this->attributes, true)) {
return $this;
}
$target = clone $this;
$target->attributes[] = $attribute;
return $target;
}
public function withMutationHashes(string ...$mutationHashes): self
{
if ($this->mutationHashes === $mutationHashes) {
return $this;
}
$target = clone $this;
$target->mutationHashes = $mutationHashes;
return $target;
}
public function jsonSerialize(): array
{
$data = parent::jsonSerialize();
$data['count'] = $this->count;
$data['attributes'] = $this->attributes;
$data['mutationHashes'] = $this->mutationHashes;
return $data;
}
}
@@ -0,0 +1,133 @@
<?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\Http\ApplicationType;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Representation of a specific application type scope (backend, frontend),
* which can optionally be enriched by site-related details.
*/
final class Scope implements \Stringable, \JsonSerializable
{
/**
* @var array<string, self>
*/
private static array $singletons = [];
/**
* @deprecated actually just `@internal` - but it might be removed later
*/
public readonly ?Site $site;
public static function backend(): self
{
return self::asSingleton(new self(ApplicationType::BACKEND));
}
public static function frontend(): self
{
return self::asSingleton(new self(ApplicationType::FRONTEND));
}
/**
* @internal might be removed later
*/
public static function frontendSite(?SiteInterface $site): self
{
if (!$site instanceof Site || is_subclass_of($site, Site::class)) {
return self::frontend();
}
return self::asSingleton(new self(ApplicationType::FRONTEND, $site->getIdentifier(), $site));
}
public static function frontendSiteIdentifier(string $siteIdentifier): self
{
return self::asSingleton(new self(ApplicationType::FRONTEND, $siteIdentifier));
}
public static function from(string $value): self
{
$parts = GeneralUtility::trimExplode('.', $value, true);
$type = ApplicationType::tryFrom($parts[0] ?? '');
$siteIdentifier = $parts[1] ?? null;
if ($type === null) {
throw new \LogicException(
sprintf('Could not resolve application type from "%s"', $value),
1677424928
);
}
return self::asSingleton(new self($type, $siteIdentifier));
}
public static function reset(): void
{
self::$singletons = [];
}
public static function tryFrom(string $value): ?self
{
try {
return self::from($value);
} catch (\LogicException) {
return null;
}
}
private static function asSingleton(self $self): self
{
$id = (string)$self;
if (!isset(self::$singletons[$id])) {
self::$singletons[$id] = $self;
}
return self::$singletons[$id];
}
/**
* Use static functions to create singleton instances.
*/
private function __construct(
public readonly ApplicationType $type,
public readonly ?string $siteIdentifier = null,
?Site $site = null,
) {
$this->site = $site;
}
public function __toString(): string
{
$value = $this->type->value;
if ($this->siteIdentifier !== null) {
$value .= '.' . $this->siteIdentifier;
}
return $value;
}
public function isFrontendSite(): bool
{
return $this->siteIdentifier !== null && $this->type->isFrontend();
}
public function jsonSerialize(): string
{
return (string)$this;
}
}
@@ -0,0 +1,51 @@
<?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\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* @internal
*/
readonly class ScopeRepository
{
public function __construct(protected SiteFinder $siteFinder) {}
/**
* @return list<Scope>
*/
public function findAll(): array
{
return array_merge(
[Scope::backend(), Scope::frontend()],
$this->findAllFrontendSites()
);
}
/**
* @return list<Scope>
*/
public function findAllFrontendSites(): array
{
return array_map(
static fn(SiteInterface $site) => Scope::frontendSiteIdentifier($site->getIdentifier()),
array_values($this->siteFinder->getAllSites())
);
}
}
@@ -0,0 +1,185 @@
<?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\Domain\EqualityInterface;
/**
* A collection of sources (sic!).
* @internal This implementation still might be adjusted
*/
final class SourceCollection
{
/**
* @var list<SourceInterface>
*/
public readonly array $sources;
public function __construct(SourceInterface ...$sources)
{
$this->sources = $sources;
}
public function isEmpty(): bool
{
return $this->sources === [];
}
public function merge(self $other): self
{
return $this->with(...$other->sources);
}
public function exclude(self $other): self
{
return $this->without(...$other->sources);
}
public function with(SourceInterface ...$subjects): self
{
$uniqueSubjects = [];
foreach ($subjects as $subject) {
if (!(in_array($subject, $uniqueSubjects, true)
|| ($subject instanceof EqualityInterface && $this->hasEqualSource($subject, ...$uniqueSubjects)))
&& !(in_array($subject, $this->sources, true)
|| ($subject instanceof EqualityInterface && $this->hasEqualSource($subject, ...$this->sources)))
) {
$uniqueSubjects[] = $subject;
}
}
if ($uniqueSubjects === []) {
return $this;
}
return new self(...array_merge($this->sources, $uniqueSubjects));
}
public function without(SourceInterface ...$subjects): self
{
$sources = array_filter(
$this->sources,
fn($source) => !(in_array($source, $subjects, true)
|| ($source instanceof EqualityInterface && $this->hasEqualSource($source, ...$subjects)))
);
if (count($this->sources) === count($sources)) {
return $this;
}
return new self(...$sources);
}
/**
* @param class-string ...$subjectTypes
*/
public function withoutTypes(string ...$subjectTypes): self
{
$sources = array_filter(
$this->sources,
fn($source) => !$this->isSourceOfTypes($source, ...$subjectTypes)
);
if (count($this->sources) === count($sources)) {
return $this;
}
return new self(...$sources);
}
/**
* Determines whether all sources are contained (in terms of instances and values, but without inference).
*/
public function contains(SourceInterface ...$subjects): bool
{
if ($subjects === []) {
return false;
}
foreach ($subjects as $subject) {
if ($subject instanceof EqualityInterface) {
if (!$this->hasEqualSource($subject, ...$this->sources)) {
return false;
}
} elseif (!in_array($subject, $this->sources, true)) {
return false;
}
}
return true;
}
/**
* Determines whether all sources are covered (in terms of CSP inference, considering wildcards and similar).
*/
public function covers(SourceInterface ...$subjects): bool
{
if ($subjects === []) {
return false;
}
foreach ($subjects as $subject) {
if ($subject instanceof CoveringInterface) {
if (!$this->hasCoveredSource($subject)) {
return false;
}
} elseif (!in_array($subject, $this->sources, true)) {
return false;
}
}
return true;
}
/**
* Determines whether at least one type matches.
* @param class-string ...$subjectTypes
*/
public function containsTypes(string ...$subjectTypes): bool
{
foreach ($this->sources as $source) {
if ($this->isSourceOfTypes($source, ...$subjectTypes)) {
return true;
}
}
return false;
}
private function hasEqualSource(EqualityInterface $subject, SourceInterface ...$sources): bool
{
foreach ($sources as $source) {
if ($source instanceof EqualityInterface && $source->equals($subject)) {
return true;
}
}
return false;
}
private function hasCoveredSource(CoveringInterface $subject): bool
{
foreach ($this->sources as $source) {
if ($source instanceof CoveringInterface && $source->covers($subject)) {
return true;
}
}
return false;
}
/**
* @param class-string ...$types
*/
private function isSourceOfTypes(SourceInterface $source, string ...$types): bool
{
foreach ($types as $type) {
if (is_a($source, $type)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,27 @@
<?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;
/**
* Semantic interface for anything that can be used as "source" in the terms
* of Content-Security-Policy - it includes `enum` objects, as well as real
* object instances that would be using `SourceValueInterface` instead.
*
* @internal This implementation might still change
*/
interface SourceInterface {}
@@ -0,0 +1,106 @@
<?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;
/**
* Representation of Content-Security-Policy source keywords
* see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
*/
enum SourceKeyword: string implements SourceInterface
{
case none = 'none';
case self = 'self';
case unsafeInline = 'unsafe-inline';
case unsafeEval = 'unsafe-eval';
// see https://www.w3.org/TR/CSP3/#unsafe-hashes-usage
case unsafeHashes = 'unsafe-hashes';
case wasmUnsafeEval = 'wasm-unsafe-eval';
case reportSample = 'report-sample';
case strictDynamic = 'strict-dynamic';
// nonce proxy is substituted when compiling the whole policy
// (this value does NOT exist in the CSP definition, it's specific to TYPO3 only)
case nonceProxy = 'nonce-proxy';
public function vetoes(): bool
{
return $this === self::none;
}
public function isApplicable(Directive $directive): bool
{
$onlyApplicableTo = self::onlyApplicableToMap();
return !isset($onlyApplicableTo[$this]) || in_array($directive, $onlyApplicableTo[$this], true);
}
/**
* @return list<Directive>
* @internal
*/
public function getApplicableDirectives(): array
{
$onlyApplicableTo = self::onlyApplicableToMap();
return $onlyApplicableTo[$this] ?? [];
}
public function applySourceImplications(SourceCollection $sources): ?SourceCollection
{
// apply implications for `'strict-dynamic'`
if ($this === self::strictDynamic) {
// add nonce-proxy in case it's not defined
if (!$sources->contains(self::nonceProxy)) {
return $sources->with(self::nonceProxy);
}
}
return null;
}
/**
* @return \WeakMap<self, list<Directive>>
*/
private static function onlyApplicableToMap(): \WeakMap
{
/** @var \WeakMap<self, list<Directive>> $map temporary, internal \WeakMap */
$map = new \WeakMap();
$map[self::reportSample] = [
...Directive::ScriptSrc->getFamily(),
...Directive::StyleSrc->getFamily(),
];
$map[self::strictDynamic] = [
...Directive::ScriptSrc->getFamily(),
];
$map[self::unsafeHashes] = [
Directive::DefaultSrc,
...Directive::ScriptSrc->getFamily(),
...Directive::StyleSrc->getFamily(),
];
$map[self::unsafeInline] = [
Directive::DefaultSrc,
...Directive::ScriptSrc->getFamily(),
...Directive::StyleSrc->getFamily(),
];
// `'nonce-*'` cannot be used in
// + `script-src-attr` (e.g. `onclick="alert(123)"`),
// + `style-src-attr` (e.g. `style="color: #fff`)
$map[self::nonceProxy] = [
Directive::DefaultSrc,
Directive::ScriptSrc, Directive::ScriptSrcElem,
Directive::StyleSrc, Directive::StyleSrcElem,
];
return $map;
}
}
@@ -0,0 +1,34 @@
<?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;
/**
* Representation of Content-Security-Policy source schemes
* see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
*/
enum SourceScheme: string implements SourceInterface
{
case blob = 'blob';
case data = 'data';
case filesystem = 'filesystem';
case http = 'http';
case https = 'https';
case mediastream = 'mediastream';
case ws = 'ws';
case wss = 'wss';
}
@@ -0,0 +1,54 @@
<?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;
/**
* Interface used for self-contained source value models.
* The parent `SourceInterface` is basically just a type interface, since
* type cannot be declared better in PHP. This `SourceValueInterface` is
* focussed on real class instances, but not on `enum` objects.
*
* @internal This implementation might still change
*/
interface SourceValueInterface extends SourceInterface
{
/**
* Determines whether a serialized representation is known and can be handled
* by a specific implementation, (e.g. "string starts with 'hash-proxy-").
*/
public static function knows(string $value): bool;
/**
* Parses a known serialized representation as object representation.
*/
public static function parse(string $value): self;
/**
* Compiled representation to be used for Content-Security-Policy HTTP header.
* @return ?string `null` means "not applicable / skip"
*/
public function compile(?FrontendInterface $cache = null): ?string;
/**
* Serialized representation to be used for persisting declaration (e.g. in database).
* @return ?string `null` means "not applicable / skip"
*/
public function serialize(): ?string;
}
@@ -0,0 +1,157 @@
<?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 Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Domain\EqualityInterface;
use TYPO3\CMS\Core\Http\Uri;
/**
* Bridge to UriInterface to be used in Content-Security-Policy models,
* which e.g. supports wildcard domains, like `*.typo3.org` or `https://*.typo3.org`.
*/
final class UriValue extends Uri implements \Stringable, EqualityInterface, CoveringInterface, SourceInterface
{
private string $domainName = '';
private bool $entireWildcard = false;
private bool $domainWildcard = false;
public static function fromUri(UriInterface $other): self
{
return new self((string)$other);
}
protected function validate(): bool
{
$backupHost = null;
if ($this->host) {
$backupHost = $this->host;
$this->host = str_replace('*', 'wildcard', $this->host);
}
$ret = parent::validate();
if ($backupHost !== null) {
$this->host = $backupHost;
}
return $ret;
}
public function __toString(): string
{
if ($this->entireWildcard) {
return '*';
}
if ($this->domainName !== '') {
return ($this->domainWildcard ? '*.' : '') . $this->domainName;
}
return parent::__toString();
}
public function equals(EqualityInterface $other): bool
{
return $other instanceof self && (string)$other === (string)$this;
}
public function covers(CoveringInterface $other): bool
{
if (!$other instanceof self) {
return false;
}
// `*` matches anything
if ($this->entireWildcard) {
return true;
}
// `*.example.com` or `example.com`
if ($this->domainName !== '') {
if ($this->domainWildcard) {
if (($other->domainName !== '' && str_ends_with($other->domainName, '.' . $this->domainName))
|| ($other->host !== '' && str_ends_with($other->host, '.' . $this->domainName))
) {
return true;
}
} else {
if (($other->domainName !== '' && $other->domainName === $this->domainName)
|| ($other->host !== '' && $other->host === $this->domainName)
) {
return true;
}
}
}
// `https://*.example.com`
if ($other->host !== ''
&& $this->scheme === $other->scheme
&& str_starts_with($this->host, '*.')
&& str_ends_with($other->host, substr($this->host, 1))
) {
return true;
}
return str_starts_with((string)$other, (string)$this);
}
public function getDomainName(): string
{
return $this->domainName;
}
protected function parseUri(string $uri): void
{
if ($uri === '*') {
$this->entireWildcard = true;
return;
}
parent::parseUri($uri);
// ignore fragments per default
$this->fragment = '';
// handle domain names that were recognized as paths
if ($this->canBeParsedAsWildcardDomainName()) {
$this->domainName = substr($this->path, 4);
$this->domainWildcard = true;
} elseif ($this->canBeParsedAsDomainName()) {
$this->domainName = $this->path;
}
}
private function canBeParsedAsDomainName(): bool
{
return $this->path !== ''
&& $this->scheme === ''
&& $this->host === ''
&& $this->query === ''
&& $this->userInfo === ''
&& $this->validateDomainName($this->path);
}
private function canBeParsedAsWildcardDomainName(): bool
{
if ($this->path === ''
|| $this->scheme !== ''
|| $this->host !== ''
|| $this->query !== ''
|| $this->userInfo !== ''
|| !str_starts_with($this->path, '%2A')
) {
return false;
}
$possibleDomainName = substr($this->path, 4);
return $this->validateDomainName($possibleDomainName);
}
private function validateDomainName(string $value): bool
{
return filter_var($value, FILTER_VALIDATE_DOMAIN) !== false;
}
}