TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
|
||||
#[Autoconfigure(public: true)]
|
||||
final readonly class AllowedCallableAssertion
|
||||
{
|
||||
/**
|
||||
* @param list<array{class-string, string}> $items
|
||||
*/
|
||||
public function __construct(private array $items = []) {}
|
||||
|
||||
/**
|
||||
* @param string|array $callable
|
||||
*/
|
||||
public function assertCallable(string|array $callable): void
|
||||
{
|
||||
// fall-back to using reflection
|
||||
$isTrusted = $this->isTrusted($callable);
|
||||
if ($isTrusted === null) {
|
||||
throw new AllowedCallableException(
|
||||
sprintf('Unexpected callable reference: %s', $this->stringifyCallable($callable)),
|
||||
1758626231
|
||||
);
|
||||
}
|
||||
if ($isTrusted === false) {
|
||||
throw new AllowedCallableException(
|
||||
sprintf(
|
||||
'Attribute %s required for callback reference: %s',
|
||||
AsAllowedCallable::class,
|
||||
$this->stringifyCallable($callable)
|
||||
),
|
||||
1758626232
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function isTrusted(string|array $callable): ?bool
|
||||
{
|
||||
if (is_string($callable)) {
|
||||
$callable = [$callable];
|
||||
}
|
||||
if (count($callable) === 1) {
|
||||
if ((is_string($callable[0]) && function_exists($callable[0])) || $callable[0] instanceof \Closure) {
|
||||
return $this->hasMatchingAttributes(
|
||||
(new \ReflectionFunction($callable[0]))->getAttributes(AsAllowedCallable::class)
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (count($callable) === 2
|
||||
&& is_string($callable[1])
|
||||
&& (
|
||||
(is_string($callable[0]) && class_exists($callable[0]))
|
||||
|| is_object($callable[0])
|
||||
)
|
||||
) {
|
||||
// lookup autoconfigured attributes
|
||||
$mapKey = [is_object($callable[0]) ? get_class($callable[0]) : $callable[0], $callable[1]];
|
||||
if (in_array($mapKey, $this->items, true)) {
|
||||
return true;
|
||||
}
|
||||
// fall-back to using reflection
|
||||
return $this->hasMatchingAttributes(
|
||||
(new \ReflectionMethod(...$callable))->getAttributes(AsAllowedCallable::class),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hasMatchingAttributes(array $attributes): bool
|
||||
{
|
||||
return $attributes !== [];
|
||||
}
|
||||
|
||||
private function stringifyCallable(string|array $callable): string
|
||||
{
|
||||
if (is_array($callable)) {
|
||||
$callable = array_map(
|
||||
static fn(mixed $value): mixed => is_object($value) ? get_class($value) : $value,
|
||||
$callable
|
||||
);
|
||||
}
|
||||
return json_encode($callable, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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;
|
||||
|
||||
use TYPO3\CMS\Core\Exception;
|
||||
|
||||
final class AllowedCallableException extends Exception {}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* Blocks object being using `unserialize()` invocations.
|
||||
*
|
||||
* Initially this trait blocked `serialize()` as well, which caused
|
||||
* a couple of side-effects in user-land code and is not problematic
|
||||
* from a security point of view.
|
||||
*/
|
||||
trait BlockSerializationTrait
|
||||
{
|
||||
/**
|
||||
* Deny object deserialization.
|
||||
*/
|
||||
public function __wakeup()
|
||||
{
|
||||
throw new \BadMethodCallException('Cannot unserialize ' . __CLASS__, 1588784142);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?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;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
/**
|
||||
* Trait providing support for JWT using symmetric hash signing.
|
||||
*
|
||||
* The benefit of using a trait in this particular case is, that defaults in `self::class`
|
||||
* (used as context during key derivation) are specific to a particular implementation.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
trait JwtTrait
|
||||
{
|
||||
private static function getDefaultSigningAlgorithm(): string
|
||||
{
|
||||
return 'HS256';
|
||||
}
|
||||
|
||||
private static function deriveKey(
|
||||
#[\SensitiveParameter]
|
||||
string $baseKey,
|
||||
string $context,
|
||||
): Key {
|
||||
$jwtAlgo = self::getDefaultSigningAlgorithm();
|
||||
[$hashAlgo, $length] = match ($jwtAlgo) {
|
||||
'HS256' => ['sha256', 32],
|
||||
'HS384' => ['sha384', 48],
|
||||
'HS512' => ['sha512', 64],
|
||||
default => throw new \InvalidArgumentException('Unsupported JWT algorithm: ' . $jwtAlgo, 1774954888),
|
||||
};
|
||||
return new Key(
|
||||
hash_hkdf(
|
||||
algo: $hashAlgo,
|
||||
key: $baseKey,
|
||||
length: $length,
|
||||
info: $context,
|
||||
),
|
||||
$jwtAlgo
|
||||
);
|
||||
}
|
||||
|
||||
private static function createSigningKeyFromEncryptionKey(string $context = self::class): Key
|
||||
{
|
||||
return self::deriveKey(
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? '',
|
||||
$context === '' ? self::class : $context
|
||||
);
|
||||
}
|
||||
|
||||
private static function createSigningSecret(SigningSecretInterface $secret, string $context = self::class): Key
|
||||
{
|
||||
return self::deriveKey(
|
||||
$secret->getSigningSecret(),
|
||||
$context === '' ? self::class : $context
|
||||
);
|
||||
}
|
||||
|
||||
private static function encodeHashSignedJwt(array $payload, Key $key, ?SecretIdentifier $identifier = null): string
|
||||
{
|
||||
$keyId = $identifier !== null ? json_encode($identifier) : null;
|
||||
return JWT::encode($payload, $key->getKeyMaterial(), $key->getAlgorithm(), $keyId);
|
||||
}
|
||||
|
||||
private static function decodeJwt(string $jwt, Key $key, bool $associative = false): \stdClass|array
|
||||
{
|
||||
$payload = JWT::decode($jwt, $key);
|
||||
return $associative ? json_decode(json_encode($payload), true) : $payload;
|
||||
}
|
||||
|
||||
private static function decodeJwtHeader(string $jwt, string $property): mixed
|
||||
{
|
||||
$parts = explode('.', $jwt);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
$headerRaw = JWT::urlsafeB64Decode($parts[0]);
|
||||
if (($header = JWT::jsonDecode($headerRaw)) === null) {
|
||||
return null;
|
||||
}
|
||||
return $header->{$property} ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?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;
|
||||
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\StringUtility;
|
||||
|
||||
/**
|
||||
* Number used once...
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Nonce implements SigningSecretInterface
|
||||
{
|
||||
use JwtTrait;
|
||||
|
||||
protected const MIN_BYTES = 40;
|
||||
|
||||
public readonly string $b64;
|
||||
public readonly \DateTimeImmutable $time;
|
||||
|
||||
public static function create(int $length = self::MIN_BYTES): self
|
||||
{
|
||||
return GeneralUtility::makeInstance(self::class, random_bytes(max(self::MIN_BYTES, $length)));
|
||||
}
|
||||
|
||||
public static function fromHashSignedJwt(string $jwt): self
|
||||
{
|
||||
try {
|
||||
$payload = self::decodeJwt($jwt, self::createSigningKeyFromEncryptionKey(Nonce::class), true);
|
||||
return GeneralUtility::makeInstance(
|
||||
self::class,
|
||||
StringUtility::base64urlDecode($payload['nonce'] ?? '', true),
|
||||
\DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null)
|
||||
);
|
||||
} catch (\Throwable $t) {
|
||||
throw new NonceException('Could not reconstitute nonce', 1651771351, $t);
|
||||
}
|
||||
}
|
||||
|
||||
public function __construct(public readonly string $binary, ?\DateTimeImmutable $time = null)
|
||||
{
|
||||
if (strlen($this->binary) < self::MIN_BYTES) {
|
||||
throw new \LogicException(
|
||||
sprintf('Value must have at least %d bytes', self::MIN_BYTES),
|
||||
1651785134
|
||||
);
|
||||
}
|
||||
$this->b64 = StringUtility::base64urlEncode($this->binary);
|
||||
// drop microtime, second is the minimum date-interval
|
||||
$this->time = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::RFC3339,
|
||||
($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
|
||||
);
|
||||
}
|
||||
|
||||
public function getSigningIdentifier(): SecretIdentifier
|
||||
{
|
||||
return new SecretIdentifier('nonce', StringUtility::base64urlEncode(md5($this->binary, true)));
|
||||
}
|
||||
|
||||
public function getSigningSecret(): string
|
||||
{
|
||||
return $this->binary;
|
||||
}
|
||||
|
||||
public function toHashSignedJwt(): string
|
||||
{
|
||||
$payload = [
|
||||
'nonce' => $this->b64,
|
||||
'time' => $this->time->format(\DateTimeImmutable::RFC3339),
|
||||
];
|
||||
return self::encodeHashSignedJwt($payload, self::createSigningKeyFromEncryptionKey(Nonce::class));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
use TYPO3\CMS\Core\Exception;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class NonceException extends Exception {}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class NoncePool implements SigningProviderInterface
|
||||
{
|
||||
/**
|
||||
* maximum amount of items in pool
|
||||
*/
|
||||
protected const DEFAULT_SIZE = 5;
|
||||
|
||||
/**
|
||||
* items will expire after this amount of seconds
|
||||
*/
|
||||
protected const DEFAULT_EXPIRATION = 900;
|
||||
|
||||
/**
|
||||
* @var array{size: positive-int, expiration: int}
|
||||
*/
|
||||
protected array $options;
|
||||
|
||||
/**
|
||||
* @var array<string, Nonce>
|
||||
*/
|
||||
protected array $items;
|
||||
|
||||
/**
|
||||
* @var array<string, ?Nonce>
|
||||
*/
|
||||
protected array $changeItems = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function __construct(array $nonces = [], array $options = [])
|
||||
{
|
||||
$this->options = [
|
||||
'size' => max(1, (int)($options['size'] ?? self::DEFAULT_SIZE)),
|
||||
'expiration' => max(0, (int)($options['expiration'] ?? self::DEFAULT_EXPIRATION)),
|
||||
];
|
||||
|
||||
foreach ($nonces as $name => $value) {
|
||||
if ($value !== null && !$value instanceof Nonce) {
|
||||
throw new \LogicException(sprintf('Invalid valid for nonce "%s"', $name), 1664195013);
|
||||
}
|
||||
}
|
||||
// filter valid items
|
||||
$this->items = array_filter(
|
||||
$nonces,
|
||||
fn(?Nonce $item, string $name) => $item !== null
|
||||
&& $this->isValidNonceName($item, $name)
|
||||
&& $this->isNonceUpToDate($item),
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
// items that were not valid -> to be revoked
|
||||
$invalidItems = array_diff_key($nonces, $this->items);
|
||||
$this->changeItems = array_fill_keys(array_keys($invalidItems), null);
|
||||
}
|
||||
|
||||
public function findSigningSecret(string $name): ?Nonce
|
||||
{
|
||||
return $this->items[$name] ?? null;
|
||||
}
|
||||
|
||||
public function provideSigningSecret(): Nonce
|
||||
{
|
||||
$items = array_filter($this->changeItems);
|
||||
$nonce = reset($items);
|
||||
if (!$nonce instanceof Nonce) {
|
||||
$nonce = Nonce::create();
|
||||
$this->emit($nonce);
|
||||
}
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
public function merge(self $other): self
|
||||
{
|
||||
$this->items = array_merge($this->items, $other->items);
|
||||
$this->changeItems = array_merge($this->changeItems, $other->changeItems);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function purge(): self
|
||||
{
|
||||
$size = $this->options['size'];
|
||||
$items = array_filter($this->items);
|
||||
if (count($items) <= $size) {
|
||||
return $this;
|
||||
}
|
||||
uasort($items, static fn(Nonce $a, Nonce $b) => $b->time <=> $a->time);
|
||||
$exceedingItems = array_splice($items, $size, null, []);
|
||||
foreach ($exceedingItems as $name => $_) {
|
||||
$this->changeItems[$name] = null;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function emit(Nonce $nonce): self
|
||||
{
|
||||
$this->changeItems[$nonce->getSigningIdentifier()->name] = $nonce;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function revoke(Nonce $nonce): self
|
||||
{
|
||||
$this->revokeSigningSecret($nonce->getSigningIdentifier()->name);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function revokeSigningSecret(string $name): void
|
||||
{
|
||||
if (isset($this->items[$name])) {
|
||||
$this->changeItems[$name] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Nonce>
|
||||
*/
|
||||
public function getEmittableNonces(): array
|
||||
{
|
||||
return array_filter($this->changeItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getRevocableNames(): array
|
||||
{
|
||||
return array_keys(
|
||||
array_diff_key($this->changeItems, $this->getEmittableNonces())
|
||||
);
|
||||
}
|
||||
|
||||
protected function isValidNonceName(Nonce $nonce, $name): bool
|
||||
{
|
||||
return $nonce->getSigningIdentifier()->name === $name;
|
||||
}
|
||||
|
||||
protected function isNonceUpToDate(Nonce $nonce): bool
|
||||
{
|
||||
if ($this->options['expiration'] <= 0) {
|
||||
return true;
|
||||
}
|
||||
$now = new \DateTimeImmutable();
|
||||
$interval = new \DateInterval(sprintf('PT%dS', $this->options['expiration']));
|
||||
return $nonce->time->add($interval) > $now;
|
||||
}
|
||||
}
|
||||
@@ -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\PermissionSet;
|
||||
|
||||
/**
|
||||
* Represents the identity and group membership of a principal for authorization.
|
||||
*
|
||||
* Contains the core identity information needed for permission evaluation,
|
||||
* including the role level, unique identifier, group memberships, and
|
||||
* impersonation context. This is used as part of the Principal object
|
||||
* to provide context for permission checks.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class PrincipalContext
|
||||
{
|
||||
/**
|
||||
* @param PrincipalRole $role The role level of the principal
|
||||
* @param int $id The unique identifier of the principal (user ID, system ID)
|
||||
* @param array<int> $groupIds List of group IDs the principal belongs to
|
||||
* @param self|null $impersonatedBy Optional parent context when impersonation is active
|
||||
*/
|
||||
public function __construct(
|
||||
public PrincipalRole $role,
|
||||
public int $id,
|
||||
public array $groupIds = [],
|
||||
public ?self $impersonatedBy = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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\PermissionSet;
|
||||
|
||||
/**
|
||||
* Principal roles for determining privilege levels.
|
||||
*
|
||||
* Defines the role-based access control levels:
|
||||
* - SYSTEM: System-level operations (CLI, install tool internal)
|
||||
* - MAINTAINER: Install tool users, system maintenance tasks
|
||||
* - ADMIN: Backend administrators (bypasses permission checks)
|
||||
* - USER: Regular backend users (respects permission grants)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
enum PrincipalRole: string
|
||||
{
|
||||
case SYSTEM = 'system';
|
||||
case MAINTAINER = 'maintainer';
|
||||
case ADMIN = 'admin';
|
||||
case USER = 'user';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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\PermissionSet;
|
||||
|
||||
/**
|
||||
* Provides workspace and user-specific configuration during data processing operations.
|
||||
*
|
||||
* Contains context information needed for permission evaluation and data manipulation,
|
||||
* primarily used by DataHandler and related components. Includes the current workspace
|
||||
* ID for versioning support and user-specific configuration (TSconfig, preferences)
|
||||
* that may affect permission checks and data handling behavior.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProcessingContext
|
||||
{
|
||||
/**
|
||||
* @param int $workspaceId The workspace ID for the current operation (mutable for DataHandler compatibility)
|
||||
* @param array<string, mixed> $userTsConfig User TSconfig configuration array
|
||||
* @param array<string, mixed> $userPreferences User preferences array
|
||||
*/
|
||||
public function __construct(
|
||||
// @todo still required writable due to `DataHandler`
|
||||
public int $workspaceId = 0,
|
||||
public readonly array $userTsConfig = [],
|
||||
public readonly array $userPreferences = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* This class represents a value that can be either untrusted
|
||||
* (meaning it needs to be verified later) or trusted
|
||||
* (meaning it has already been verified).
|
||||
*/
|
||||
final readonly class RawValue
|
||||
{
|
||||
public function __construct(public string $value, public bool $trusted = false) {}
|
||||
|
||||
public function trust(): self
|
||||
{
|
||||
return new self($this->value, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?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;
|
||||
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RequestToken
|
||||
{
|
||||
use JwtTrait;
|
||||
|
||||
public const PARAM_NAME = '__RequestToken';
|
||||
public const HEADER_NAME = 'X-TYPO3-RequestToken';
|
||||
|
||||
public readonly string $scope;
|
||||
public readonly \DateTimeImmutable $time;
|
||||
/**
|
||||
* @var array<int|string, mixed>
|
||||
*/
|
||||
public readonly array $params;
|
||||
|
||||
/**
|
||||
* Identifier that was used for signing, filled when decoding.
|
||||
*/
|
||||
private ?SecretIdentifier $signingSecretIdentifier = null;
|
||||
|
||||
public static function create(string $scope): self
|
||||
{
|
||||
return GeneralUtility::makeInstance(self::class, $scope);
|
||||
}
|
||||
|
||||
public static function fromHashSignedJwt(string $jwt, SigningSecretInterface|SigningSecretResolver $secret): self
|
||||
{
|
||||
// invokes resolver to retrieve corresponding secret
|
||||
// a hint was stored in the `kid` (keyId) property of the JWT header
|
||||
if ($secret instanceof SigningSecretResolver) {
|
||||
try {
|
||||
$kid = (string)self::decodeJwtHeader($jwt, 'kid');
|
||||
$identifier = SecretIdentifier::fromJson($kid);
|
||||
$secret = $secret->findByIdentifier($identifier);
|
||||
} catch (\Throwable $t) {
|
||||
throw new RequestTokenException('Could not reconstitute request token', 1664202134, $t);
|
||||
}
|
||||
if ($secret === null) {
|
||||
throw new RequestTokenException('Could not reconstitute request token', 1664202135);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = self::decodeJwt($jwt, self::createSigningSecret($secret, RequestToken::class), true);
|
||||
$subject = GeneralUtility::makeInstance(
|
||||
self::class,
|
||||
$payload['scope'] ?? '',
|
||||
\DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null),
|
||||
$payload['params'] ?? []
|
||||
);
|
||||
$subject->signingSecretIdentifier = $secret->getSigningIdentifier();
|
||||
return $subject;
|
||||
} catch (\Throwable $t) {
|
||||
throw new RequestTokenException('Could not reconstitute request token', 1651771352, $t);
|
||||
}
|
||||
}
|
||||
|
||||
public function __construct(string $scope, ?\DateTimeImmutable $time = null, array $params = [])
|
||||
{
|
||||
$this->scope = $scope;
|
||||
// drop microtime, second is the minimum date-interval
|
||||
$this->time = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::RFC3339,
|
||||
($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
|
||||
);
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function toHashSignedJwt(SigningSecretInterface $secret): string
|
||||
{
|
||||
$payload = [
|
||||
'scope' => $this->scope,
|
||||
'time' => $this->time->format(\DateTimeImmutable::RFC3339),
|
||||
'params' => $this->params,
|
||||
];
|
||||
return self::encodeHashSignedJwt(
|
||||
$payload,
|
||||
self::createSigningSecret($secret, RequestToken::class),
|
||||
$secret->getSigningIdentifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function withParams(array $params): self
|
||||
{
|
||||
return GeneralUtility::makeInstance(self::class, $this->scope, $this->time, $params);
|
||||
}
|
||||
|
||||
public function withMergedParams(array $params): self
|
||||
{
|
||||
return $this->withParams(array_merge_recursive($this->params, $params));
|
||||
}
|
||||
|
||||
public function getSigningSecretIdentifier(): ?SecretIdentifier
|
||||
{
|
||||
return $this->signingSecretIdentifier;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
use TYPO3\CMS\Core\Exception;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class RequestTokenException extends Exception {}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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;
|
||||
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Model used to identify a secret, without actually containing the secret value.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
readonly class SecretIdentifier implements \JsonSerializable
|
||||
{
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
return self::fromArray(
|
||||
(array)json_decode($json, true, 8, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
$type = $payload['type'] ?? null;
|
||||
$name = $payload['name'] ?? null;
|
||||
if (!is_string($type) || !is_string($name)) {
|
||||
throw new \LogicException('Properties "type" and "name" must be of type string', 1664215980);
|
||||
}
|
||||
return GeneralUtility::makeInstance(self::class, $type, $name);
|
||||
}
|
||||
|
||||
public function __construct(public string $type, public string $name) {}
|
||||
|
||||
/**
|
||||
* @return array{type: string, name: string}
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'name' => $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface SigningProviderInterface
|
||||
{
|
||||
/**
|
||||
* Provides a signing secret independently of any name or identifier.
|
||||
* In case there is none, the corresponding provider has to create a new one.
|
||||
*/
|
||||
public function provideSigningSecret(): SigningSecretInterface;
|
||||
|
||||
/**
|
||||
* Finds a signing secret for a given name
|
||||
*/
|
||||
public function findSigningSecret(string $name): ?SigningSecretInterface;
|
||||
|
||||
/**
|
||||
* Revokes a signing secret for a given name
|
||||
* (providers without revocation functionality use an empty method body)
|
||||
*/
|
||||
public function revokeSigningSecret(string $name): void;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* Provides the value that is used as secret in a cryptographic signing process.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface SigningSecretInterface
|
||||
{
|
||||
/**
|
||||
* Returns a public identifier of the secret.
|
||||
*/
|
||||
public function getSigningIdentifier(): SecretIdentifier;
|
||||
|
||||
/**
|
||||
* Returns secret used for signing messages.
|
||||
*/
|
||||
public function getSigningSecret(): string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Resolves SigningSecretInterface items.
|
||||
*
|
||||
* @internal This class with change!
|
||||
*/
|
||||
class SigningSecretResolver
|
||||
{
|
||||
/**
|
||||
* @var array<string, SigningProviderInterface>
|
||||
*/
|
||||
protected array $providers;
|
||||
|
||||
public function __construct(array $providers)
|
||||
{
|
||||
$this->providers = array_filter(
|
||||
$providers,
|
||||
static fn($provider) => $provider instanceof SigningProviderInterface
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a signing provider by its type (e.g. `NoncePool` from type `'nonce'`)
|
||||
*/
|
||||
public function findByType(string $type): ?SigningProviderInterface
|
||||
{
|
||||
return $this->providers[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a specific signing secret by its public identifier
|
||||
* (e.g. specific `Nonce` from `NoncePool` by given public identifier "nonce:[public-name]")
|
||||
*/
|
||||
public function findByIdentifier(SecretIdentifier $identifier): ?SigningSecretInterface
|
||||
{
|
||||
if (!isset($this->providers[$identifier->type])) {
|
||||
return null;
|
||||
}
|
||||
return $this->providers[$identifier->type]->findSigningSecret($identifier->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a specific signing secret.
|
||||
*/
|
||||
public function revokeIdentifier(SecretIdentifier $identifier): void
|
||||
{
|
||||
if (!isset($this->providers[$identifier->type])) {
|
||||
return;
|
||||
}
|
||||
$this->providers[$identifier->type]->revokeSigningSecret($identifier->name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user