265 lines
8.9 KiB
PHP
265 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy;
|
|
|
|
use 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
|
|
);
|
|
}
|
|
}
|