|null system resource identifiers */ private ?array $resources = null; /** * @var list|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 $urls * @return array 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 ); } }