diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c27096e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +* text=auto eol=lf + +/.github export-ignore +/tests export-ignore +/docs export-ignore +/AGENTS.md export-ignore +/CONTRIBUTING.md export-ignore +/qa-adoption.json export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpunit*.xml* export-ignore +/.php-cs-fixer* export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.env* export-ignore +/auth.json export-ignore +/.npmrc export-ignore +/composer.review*.json export-ignore +/composer.review*.lock export-ignore diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml new file mode 100644 index 0000000..d97d5a7 --- /dev/null +++ b/.github/workflows/dependency-canary.yml @@ -0,0 +1,18 @@ +name: Dependency compatibility + +on: + schedule: + - cron: '14 4 * * 3' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compatibility: + permissions: + contents: read + issues: write + uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + with: + php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 98a7111..f43b763 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -12,10 +12,10 @@ permissions: jobs: workflows: - uses: sympress/workflows/.github/workflows/lint-workflows.yml@v1 + uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 with: php_version: '8.5' secrets: diff --git a/README.md b/README.md index 93c6ed8..7ec632c 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,82 @@ -# SymPress Assets +--- +nav_order: 5 +--- -[![Checks](https://img.shields.io/github/actions/workflow/status/SymPress/assets/qa.yml?branch=main&label=checks)](https://github.com/SymPress/assets/actions/workflows/qa.yml) [![Release](https://img.shields.io/github/v/release/SymPress/assets?label=release)](https://github.com/SymPress/assets/releases) [![PHP](https://img.shields.io/packagist/dependency-v/sympress/assets/php.svg?label=php)](https://packagist.org/packages/sympress/assets) [![Downloads](https://img.shields.io/packagist/dt/sympress/assets.svg?label=downloads)](https://packagist.org/packages/sympress/assets/stats) [![License: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE) +# Output Filters -Asset management for SymPress WordPress packages, plugins, and themes. +Output filters modify the final `script` or `link` tag. -The package registers classic scripts, script modules, and styles through a -Composer library. It can run standalone in WordPress or through the optional -Symfony kernel integration. +```php +withAttributes(['crossorigin' => 'anonymous']); ``` -## Features +## Included Filters -- Scripts, script modules, and styles with one shared asset API -- Webpack manifest, Encore, array, and PHP file loaders -- Automatic versions from file paths or dependency extraction files -- Native script loading strategies: `defer`, `async`, `blocking` -- Style preload for non-critical CSS -- Resource hints for `preload`, `preconnect`, and `dns-prefetch` -- Protection from cache, minify, combine, defer, and delay optimizations -- Optional Symfony kernel integration with asset providers +- `AttributesOutputFilter` sets additional attributes. +- `InlineAssetOutputFilter` emits small, allowed CSS or JS files inline. +- `AsyncStyleOutputFilter` loads non-critical CSS through `preload`. -## Example +`Style::preload()` requests `AsyncStyleOutputFilter`. Supply the page's intentional +per-request CSP nonce through `sympress_assets_csp_nonce` (value, asset). When a +nonempty nonce and WP_HTML_Tag_Processor are available, output contains a preload +link plus a nonce-bearing script using addEventListener, with no onload attribute. +The nonce must match the page's script-src policy; putting a nonce on a link does +not authorize inline event handlers. The script handles already completed/cache-hit +loads, and a noscript copy keeps the original stylesheet. Existing media, integrity, +crossorigin and fetchpriority are preserved. Without a nonce the original blocking +stylesheet is returned, so strict script-src-attr 'none' remains supported. -```php -register( - new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'), - new Style('site', plugin_dir_url(__FILE__) . 'assets/site.css'), - ); - } -); -``` +For scripts, `AsyncScriptOutputFilter` and `DeferScriptOutputFilter` are +deprecated. Use `Script::async()` or `Script::defer()` instead. -Classic scripts use WordPress' native `defer` strategy by default. Styles remain -blocking unless `Style::preload()` is enabled for non-critical CSS. +## Custom Filter -## Standalone WordPress +```php +', ' data-handle="' . esc_attr($asset->handle()) . '">', $html); +}; -This package is licensed under `GPL-2.0-or-later`. +$script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js')) + ->withFilters($filter); +``` -It contains code derived from `inpsyde/assets`. See [NOTICE.md](./NOTICE.md) for -details. +## Supplied SRI and request snapshots + +Loader configuration and dependency JSON may provide integrity (space-separated +sha256/384/512 base64 digests) and crossorigin (anonymous/use-credentials). Digest +format/decoded length is validated and anonymous is the default. Hashes must come +from a trusted build for the exact bytes served. The library does not synthesize +integrity from URLs/versions or fetch remote files; no remote integrity guarantee +is possible without supplied build metadata. Inline tags omit external integrity. + +RequestFiles shares canonical path/stat/content/JSON snapshots across assets and +loaders. Repeated reads and missing paths reuse a request snapshot, while allowed +root policy decisions remain local to their policy context. Call +SymPress\Assets\IO\RequestFiles::reset() at **every** request boundary in a +long-running worker, and recreate per-request asset objects. Normal PHP request +teardown already clears this memory. New/changed manifests and symlink targets +are observed after reset; do not modify build files partway through a request. +PHP dependency execution remains separately opt-in and size-bounded. + +CSP preload requires an intentional page nonce via sympress_assets_csp_nonce; +without it CSS stays blocking. Supplied build SRI and request-scoped file snapshots +are documented in [output filters](docs/output-filter.md). Long-running workers +must reset RequestFiles and recreate request asset objects at each boundary. diff --git a/composer.json b/composer.json index ad72a6e..6dcbaed 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,8 @@ "php-stubs/wordpress-stubs": ">=7.0@stable", "phpstan/phpstan-mockery": "^2.0", "symfony/var-dumper": "^8.1", - "sympress/kernel": "dev-main", - "sympress/qa": "dev-main" + "sympress/kernel": "^1.1.1", + "sympress/qa": "^0.1.2" }, "autoload": { "psr-4": { @@ -92,18 +92,9 @@ "phpstan/extension-installer": true } }, - "repositories": [ - { - "type": "path", - "url": "./../*", - "options": { - "symlink": true - } - } - ], "suggest": { "sympress/kernel": "Enables the optional Symfony-kernel bundle integration." }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true } diff --git a/docs/output-filter.md b/docs/output-filter.md index 30e3bdb..29c333f 100644 --- a/docs/output-filter.md +++ b/docs/output-filter.md @@ -21,9 +21,23 @@ $script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js')) - `InlineAssetOutputFilter` emits small, allowed CSS or JS files inline. - `AsyncStyleOutputFilter` loads non-critical CSS through `preload`. -`Style::preload()` sets `AsyncStyleOutputFilter` automatically and creates a -`noscript` fallback. Existing attributes such as `media`, `integrity`, -`crossorigin`, and `fetchpriority` are preserved. +`Style::preload()` requests `AsyncStyleOutputFilter`. Supply the page's intentional +per-request CSP nonce through `sympress_assets_csp_nonce` (value, asset). When a +nonempty nonce and WP_HTML_Tag_Processor are available, output contains a preload +link plus a nonce-bearing script using addEventListener, with no onload attribute. +The nonce must match the page's script-src policy; putting a nonce on a link does +not authorize inline event handlers. The script handles already completed/cache-hit +loads, and a noscript copy keeps the original stylesheet. Existing media, integrity, +crossorigin and fetchpriority are preserved. Without a nonce the original blocking +stylesheet is returned, so strict script-src-attr 'none' remains supported. + +Inline files must satisfy the allowed canonical path/extensions and 32KB limit, +including the actual read bytes. Raw JavaScript containing closing/opening script +prefixes, HTML comment openings, or U+2028/U+2029 falls back to its external tag. +There is no semantics-preserving universal rewrite for strings, regexps and tagged +templates; external fallback keeps the exact program bytes. CSS closing style +prefixes are escaped case-insensitively. Inline output retains nonce and removes +external-only integrity/src/loading attributes. For scripts, `AsyncScriptOutputFilter` and `DeferScriptOutputFilter` are deprecated. Use `Script::async()` or `Script::defer()` instead. @@ -43,3 +57,21 @@ $filter = static function (string $html, Asset $asset): string { $script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js')) ->withFilters($filter); ``` + +## Supplied SRI and request snapshots + +Loader configuration and dependency JSON may provide integrity (space-separated +sha256/384/512 base64 digests) and crossorigin (anonymous/use-credentials). Digest +format/decoded length is validated and anonymous is the default. Hashes must come +from a trusted build for the exact bytes served. The library does not synthesize +integrity from URLs/versions or fetch remote files; no remote integrity guarantee +is possible without supplied build metadata. Inline tags omit external integrity. + +RequestFiles shares canonical path/stat/content/JSON snapshots across assets and +loaders. Repeated reads and missing paths reuse a request snapshot, while allowed +root policy decisions remain local to their policy context. Call +SymPress\Assets\IO\RequestFiles::reset() at **every** request boundary in a +long-running worker, and recreate per-request asset objects. Normal PHP request +teardown already clears this memory. New/changed manifests and symlink targets +are observed after reset; do not modify build files partway through a request. +PHP dependency execution remains separately opt-in and size-bounded. diff --git a/docs/releases/1.2.1.md b/docs/releases/1.2.1.md new file mode 100644 index 0000000..a17c270 --- /dev/null +++ b/docs/releases/1.2.1.md @@ -0,0 +1,9 @@ +# 1.2.1 release candidate + +This candidate contains the 2026-10-01 review repairs and requires the stable +SymPress dependency train. It has not been published yet. CI consumers use the +reviewed workflows commit 177fa0d727b278d2103052ec77c102b4a4c492a0. + +Local exact-commit Composer archives verify dependency resolution and distribution +contents before publication. They do not imply that prospective tags exist on +GitHub or Packagist. Historical tags remain unchanged. diff --git a/src/AssetConfigurationApplier.php b/src/AssetConfigurationApplier.php index dadd3bc..768ef1e 100644 --- a/src/AssetConfigurationApplier.php +++ b/src/AssetConfigurationApplier.php @@ -8,6 +8,7 @@ use SymPress\Assets\CacheOptimization\CacheOptimizationExclusion; use SymPress\Assets\Performance\ResourceHint; use SymPress\Assets\Performance\ResourceHintAwareAsset; +use SymPress\Assets\Security\IntegrityMetadata; final readonly class AssetConfigurationApplier { @@ -47,6 +48,9 @@ public function apply(Asset $asset, array $config): Asset } $this->applyMappedConfig($asset, $config, $propertyMap); + if ($asset instanceof FilterAwareAsset) { + IntegrityMetadata::apply($asset, $config['integrity'] ?? null, $config['crossorigin'] ?? null); + } $this->applyDependencies($asset, $config['dependencies'] ?? null); $this->applyCacheOptimizationConfig($asset, $config['cacheOptimization'] ?? null); $this->applyResourceHintsConfig($asset, $config['resourceHints'] ?? null); diff --git a/src/AssetFactory.php b/src/AssetFactory.php index 012859a..3e98639 100644 --- a/src/AssetFactory.php +++ b/src/AssetFactory.php @@ -29,6 +29,8 @@ * location?: AssetLocation, * condition?: string, * attributes?: array, + * integrity?: string, + * crossorigin?: string, * cacheOptimization?: bool|CacheOptimizationExclusion|null, * resourceHints?: array>, * dependencyExtractionEnabled?: bool, diff --git a/src/BaseAsset.php b/src/BaseAsset.php index 795699a..1d398d3 100644 --- a/src/BaseAsset.php +++ b/src/BaseAsset.php @@ -7,6 +7,7 @@ use SymPress\Assets\CacheOptimization\CacheOptimizationAwareAsset; use SymPress\Assets\CacheOptimization\CacheOptimizationAwareTrait; use SymPress\Assets\Handler\AssetHandler; +use SymPress\Assets\IO\RequestFiles; use SymPress\Assets\Performance\ResourceHintAwareAsset; use SymPress\Assets\Performance\ResourceHintAwareTrait; use SymPress\Assets\Util\AssetPathResolver; @@ -118,11 +119,12 @@ public function version(): ?string if ($version === null && $this->autodiscoverVersion) { $filePath = $this->filePath(); - if ($filePath === '' || !is_file($filePath)) { + $info = RequestFiles::shared()->info($filePath); + if ($filePath === '' || !$info['file']) { return null; } - $version = (string) filemtime($filePath); + $version = (string) $info['modified']; $this->withVersion($version); return $version; diff --git a/src/DependencyExtractionTrait.php b/src/DependencyExtractionTrait.php index 2add118..1d1db3b 100644 --- a/src/DependencyExtractionTrait.php +++ b/src/DependencyExtractionTrait.php @@ -6,6 +6,7 @@ use SymPress\Assets\Exception\InvalidResourceException; use SymPress\Assets\Loader\JsonFileReader; +use SymPress\Assets\IO\RequestFiles; use SymPress\Assets\Security\DependencyFilePolicy; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -56,9 +57,7 @@ protected function resolveDependencyExtractionPlugin(): bool if ($this->resolvedDependencyExtractionPlugin) { return false; } - $depsFile = $this->findDependencyFile( - new DependencyFilePolicy($this->phpDependencyFilesAllowed, $this->dependencyFileMaxBytes), - ); + $depsFile = $this->findDependencyFile(); $this->resolvedDependencyExtractionPlugin = true; if (!$depsFile) { @@ -70,6 +69,9 @@ protected function resolveDependencyExtractionPlugin(): bool ? (new JsonFileReader())->read($depsFilePath) : $this->loadPhpDependencyFile($depsFilePath); + if ($this instanceof FilterAwareAsset) { + \SymPress\Assets\Security\IntegrityMetadata::apply($this, $data['integrity'] ?? null, $data['crossorigin'] ?? null); + } $dependencies = $this->normalizeDependencyHandles($data['dependencies'] ?? []); $version = $this->normalizeDependencyVersion($data['version'] ?? null); @@ -97,6 +99,18 @@ protected function findDepdendencyFile(): ?\SplFileInfo } protected function findDependencyFile(?DependencyFilePolicy $policy = null): ?\SplFileInfo + { + // Explicit policy objects may have a different trust/size boundary; do not reuse their result. + if ($policy !== null) { + return $this->scanDependencyFile($policy); + } + return RequestFiles::shared()->remember( + 'dependency:' . $this->filePath() . ':' . (int) $this->phpDependencyFilesAllowed . ':' . $this->dependencyFileMaxBytes, + fn (): ?\SplFileInfo => $this->scanDependencyFile(), + ); + } + + private function scanDependencyFile(?DependencyFilePolicy $policy = null): ?\SplFileInfo { $policy ??= new DependencyFilePolicy($this->phpDependencyFilesAllowed, $this->dependencyFileMaxBytes); @@ -169,7 +183,7 @@ private function dependencyFileFromPath( ?DependencyFilePolicy $policy = null, ): ?\SplFileInfo { - if (!is_file($filePath)) { + if (!RequestFiles::shared()->info($filePath)['file']) { return null; } diff --git a/src/IO/RequestFiles.php b/src/IO/RequestFiles.php new file mode 100644 index 0000000..898ef6c --- /dev/null +++ b/src/IO/RequestFiles.php @@ -0,0 +1,80 @@ + */ + private array $values = []; + private int $observations = 0; + + public static function shared(): self + { + return self::$shared ??= new self(); + } + + public static function reset(): void + { + self::$shared = null; + clearstatcache(); + } + + public function observations(): int + { + return $this->observations; + } + + /** + * @template T + * @param callable(): T $read + * @return T + */ + public function remember(string $key, callable $read): mixed + { + if (!array_key_exists($key, $this->values)) { + $this->values[$key] = $read(); + } + /** @var T $value */ + $value = $this->values[$key]; + return $value; + } + + /** @return array{canonical: string|null, size: int, modified: int, readable: bool, file: bool} */ + public function info(string $path): array + { + return $this->remember('info:' . $path, function () use ($path): array { + ++$this->observations; + clearstatcache(true, $path); + $canonical = realpath($path); + $stat = @stat($path); + return [ + 'canonical' => $canonical === false ? null : $canonical, + 'size' => $stat === false ? 0 : $stat['size'], + 'modified' => $stat === false ? 0 : $stat['mtime'], + 'readable' => is_readable($path), + 'file' => is_file($path), + ]; + }); + } + + public function contents(string $path): ?string + { + $info = $this->info($path); + $canonical = $info['canonical']; + if ($canonical === null && $info['file'] && $info['readable'] && str_contains($path, '://')) { + $canonical = $path; + } + if ($canonical === null) { + return null; + } + return $this->remember('contents:' . $canonical, function () use ($canonical): ?string { + ++$this->observations; + $content = file_get_contents($canonical); + return $content === false ? null : $content; + }); + } +} diff --git a/src/Loader/EncoreManifest.php b/src/Loader/EncoreManifest.php index 4d0a811..7cff0ce 100644 --- a/src/Loader/EncoreManifest.php +++ b/src/Loader/EncoreManifest.php @@ -13,15 +13,15 @@ final class EncoreManifest */ public static function read(string $file): ?array { - if (!is_readable($file)) { + if (!\SymPress\Assets\IO\RequestFiles::shared()->info($file)['readable']) { return null; } - $contents = file_get_contents($file); - if ($contents === false) { + $contents = \SymPress\Assets\IO\RequestFiles::shared()->contents($file); + if ($contents === null) { return null; } try { - $manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + $manifest = \SymPress\Assets\IO\RequestFiles::shared()->remember('json:' . $file, static fn (): mixed => json_decode($contents, true, 512, JSON_THROW_ON_ERROR)); } catch (\JsonException) { return null; } diff --git a/src/Loader/JsonFileReader.php b/src/Loader/JsonFileReader.php index 907e110..c6b5014 100644 --- a/src/Loader/JsonFileReader.php +++ b/src/Loader/JsonFileReader.php @@ -19,16 +19,16 @@ public function read(mixed $resource): array { $resource = $this->readableFile($resource); - $contents = file_get_contents($resource); + $contents = \SymPress\Assets\IO\RequestFiles::shared()->contents($resource); - if ($contents === false) { + if ($contents === null) { throw new FileNotFoundException( sprintf('The given file "%s" does not exist or is not readable.', esc_html($resource)), ); } try { - $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + $data = \SymPress\Assets\IO\RequestFiles::shared()->remember('json:' . $resource, static fn (): mixed => json_decode($contents, true, flags: JSON_THROW_ON_ERROR)); } catch (\JsonException $exception) { throw new InvalidResourceException( sprintf('Error parsing JSON - %s', esc_html($exception->getMessage())), @@ -56,7 +56,7 @@ public function read(mixed $resource): array #[\NoDiscard] public function readableFile(mixed $resource): string { - if (is_string($resource) && is_readable($resource)) { + if (is_string($resource) && \SymPress\Assets\IO\RequestFiles::shared()->info($resource)['readable']) { return $resource; } diff --git a/src/OutputFilter/AsyncStyleOutputFilter.php b/src/OutputFilter/AsyncStyleOutputFilter.php index 798a6af..3241c6f 100644 --- a/src/OutputFilter/AsyncStyleOutputFilter.php +++ b/src/OutputFilter/AsyncStyleOutputFilter.php @@ -8,88 +8,30 @@ class AsyncStyleOutputFilter implements AssetOutputFilter { - private const string LOAD_STYLESHEET_ON_LOAD = "this.onload=null;this.rel='stylesheet'"; + public const string NONCE_FILTER = 'sympress_assets_csp_nonce'; public function __invoke(string $html, FilterAwareAsset $asset): string { - $attributes = [ - ...$this->linkAttributes($html, $asset), - ...$asset->attributes(), - 'rel' => 'preload', - 'as' => 'style', - 'onload' => self::LOAD_STYLESHEET_ON_LOAD, - ]; - - unset($attributes['id']); - - return sprintf( - '', - HtmlAttributes::render($attributes), - $html, - ); - } - - /** @return array */ - private function linkAttributes(string $html, FilterAwareAsset $asset): array - { - $attributes = $this->parseAttributes($html); - $href = $attributes['href'] ?? $this->assetUrl($asset); - - return [ - ...$attributes, - 'href' => esc_url((string) $href), - ]; - } - - private function assetUrl(FilterAwareAsset $asset): string - { - $url = $asset->url(); - $version = $asset->version(); - - return $version - ? add_query_arg('ver', $version, $url) - : $url; - } - - /** @return array */ - private function parseAttributes(string $html): array - { - if (preg_match('/[^>]*)>/i', $html, $tag) !== 1) { - return []; + $nonce = function_exists('apply_filters') ? apply_filters(self::NONCE_FILTER, null, $asset) : null; + if (!is_string($nonce) || $nonce === '' || !class_exists(\WP_HTML_Tag_Processor::class)) { + return $html; } - - $attributes = []; - - if ( - preg_match_all( - '/\s(?[A-Za-z_:][A-Za-z0-9:_.-]*)\s*=\s*(["\'])(?.*?)\2/s', - (string) $tag['attributes'], - $matches, - PREG_SET_ORDER, - ) - ) { - foreach ($matches as $match) { - $attributes[(string) $match['name']] = html_entity_decode( - (string) $match['value'], - ENT_QUOTES | ENT_HTML5, - 'UTF-8', - ); - } - } - - if ( - preg_match_all( - '/\s(?[A-Za-z_:][A-Za-z0-9:_.-]*)(?=\s|$)/', - preg_replace('/\s[A-Za-z_:][A-Za-z0-9:_.-]*\s*=\s*(["\']).*?\1/s', '', (string) $tag['attributes']) ?? '', - $matches, - PREG_SET_ORDER, - ) - ) { - foreach ($matches as $match) { - $attributes[(string) $match['name']] ??= true; - } + $tags = new \WP_HTML_Tag_Processor($html); + if (!$tags->next_tag(['tag_name' => 'LINK'])) { + return $html; } - - return $attributes; + $id = 'sympress-async-' . bin2hex(random_bytes(8)); + HtmlAttributes::applyToTag($tags, $asset->attributes(), ['id', 'rel', 'as', 'onload']); + $tags->set_attribute('id', $id); + $tags->set_attribute('rel', 'preload'); + $tags->set_attribute('as', 'style'); + $tags->remove_attribute('onload'); + $identifier = json_encode($id, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + $script = '(function(){const link=document.getElementById(' . $identifier . ');' + . 'if(!link)return;const activate=()=>{link.rel="stylesheet";};' + . 'link.addEventListener("load",activate,{once:true});' + . 'if(link.sheet||(window.performance&&performance.getEntriesByName(link.href).some(e=>e.responseEnd>0)))activate();})();'; + // A completed Resource Timing entry handles a preload event that fired before this script. + return $tags->get_updated_html() . ' $nonce]) . '>' . $script . ''; } } diff --git a/src/OutputFilter/InlineAssetOutputFilter.php b/src/OutputFilter/InlineAssetOutputFilter.php index 916a4af..2471146 100644 --- a/src/OutputFilter/InlineAssetOutputFilter.php +++ b/src/OutputFilter/InlineAssetOutputFilter.php @@ -13,9 +13,6 @@ final class InlineAssetOutputFilter implements AssetOutputFilter { private readonly InlineAssetPolicy $policy; - /** @var array */ - private array $contentsByFile = []; - public function __construct(?InlineAssetPolicy $policy = null) { $this->policy = $policy ?? InlineAssetPolicy::fromWordPressEnvironment(); @@ -35,11 +32,15 @@ public function __invoke(string $html, FilterAwareAsset $asset): string } $content = $this->fileContent($filePath); - if ($content === null) { + if ($content === null || !$this->policy->allowsContent($content)) { return $html; } if ($asset instanceof Script) { + // Raw programs cannot be rewritten safely in every string, regexp or tagged-template parser state. + if (preg_match('~%2$s', $this->attributes($asset, ['src', 'href', 'rel', 'integrity', 'defer', 'async', 'as']), @@ -60,16 +61,7 @@ public function __invoke(string $html, FilterAwareAsset $asset): string private function fileContent(string $filePath): ?string { - $modifiedAt = @filemtime($filePath); - $cacheKey = sprintf('%s:%s', $filePath, $modifiedAt === false ? 'unknown' : (string) $modifiedAt); - if (array_key_exists($cacheKey, $this->contentsByFile)) { - return $this->contentsByFile[$cacheKey]; - } - - $content = file_get_contents($filePath); - $this->contentsByFile[$cacheKey] = $content === false ? null : $content; - - return $this->contentsByFile[$cacheKey]; + return \SymPress\Assets\IO\RequestFiles::shared()->contents($filePath); } /** @param list $excludedAttributes */ @@ -87,9 +79,9 @@ private function attributes(FilterAwareAsset $asset, array $excludedAttributes): private function safeRawText(string $content, string $tagName): string { - return preg_replace( + return preg_replace_callback( sprintf('/<\\/%s/i', preg_quote($tagName, '/')), - '<\/' . $tagName, + static fn (array $match): string => '<\\/' . substr($match[0], 2), $content, ) ?? $content; } diff --git a/src/Security/FilesystemPathPolicy.php b/src/Security/FilesystemPathPolicy.php index 8c8650a..6071107 100644 --- a/src/Security/FilesystemPathPolicy.php +++ b/src/Security/FilesystemPathPolicy.php @@ -105,6 +105,6 @@ private static function normalizeDirectories(array $directories): array private static function canonicalPath(string $path): ?string { - return FilesystemPath::canonical($path); + return \SymPress\Assets\IO\RequestFiles::shared()->info($path)['canonical']; } } diff --git a/src/Security/InlineAssetPolicy.php b/src/Security/InlineAssetPolicy.php index 3d1797b..32f32df 100644 --- a/src/Security/InlineAssetPolicy.php +++ b/src/Security/InlineAssetPolicy.php @@ -43,9 +43,15 @@ public static function fromWordPressEnvironment(array $additionalAllowedDirector ); } + public function allowsContent(string $content): bool + { + return strlen($content) <= $this->maxBytes; + } + public function allows(Asset $asset, string $filePath): bool { - if (!is_file($filePath) || !is_readable($filePath)) { + $info = \SymPress\Assets\IO\RequestFiles::shared()->info($filePath); + if (!$info['file'] || !$info['readable']) { return false; } @@ -53,7 +59,7 @@ public function allows(Asset $asset, string $filePath): bool return false; } - $size = filesize($filePath); + $size = $info['size']; if (!is_int($size) || $size > $this->maxBytes) { return false; } diff --git a/src/Security/IntegrityMetadata.php b/src/Security/IntegrityMetadata.php new file mode 100644 index 0000000..4c763a2 --- /dev/null +++ b/src/Security/IntegrityMetadata.php @@ -0,0 +1,35 @@ +withAttributes(['integrity' => $integrity, 'crossorigin' => $crossorigin ?? 'anonymous']); + } +} diff --git a/tests/phpunit/Support/WP_HTML_Tag_Processor.php b/tests/phpunit/Support/WP_HTML_Tag_Processor.php index 96886ea..9e0f70b 100644 --- a/tests/phpunit/Support/WP_HTML_Tag_Processor.php +++ b/tests/phpunit/Support/WP_HTML_Tag_Processor.php @@ -61,10 +61,16 @@ public function set_attribute(string $name, string $value): void return; } + $this->remove_attribute($name); $attribute = sprintf(' %s="%s"', $name, $value); $this->updatedTag = preg_replace('/\s*\/?>$/', $attribute . '$0', $this->updatedTag) ?? $this->updatedTag; } + public function remove_attribute(string $name): void + { + $this->updatedTag = preg_replace('/\s' . preg_quote($name, '/') . '(?:=(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?/i', '', $this->updatedTag) ?? $this->updatedTag; + } + public function get_updated_html(): string { if ('' === $this->tag) { diff --git a/tests/phpunit/Unit/AbstractTestCase.php b/tests/phpunit/Unit/AbstractTestCase.php index 9115ef7..8229209 100644 --- a/tests/phpunit/Unit/AbstractTestCase.php +++ b/tests/phpunit/Unit/AbstractTestCase.php @@ -15,6 +15,7 @@ abstract class AbstractTestCase extends TestCase protected function setUp(): void { parent::setUp(); + \SymPress\Assets\IO\RequestFiles::reset(); Monkey\setUp(); } diff --git a/tests/phpunit/Unit/Bootstrap/WordPressHookRegistrarTest.php b/tests/phpunit/Unit/Bootstrap/WordPressHookRegistrarTest.php index 1515398..a97a7d6 100644 --- a/tests/phpunit/Unit/Bootstrap/WordPressHookRegistrarTest.php +++ b/tests/phpunit/Unit/Bootstrap/WordPressHookRegistrarTest.php @@ -39,11 +39,8 @@ protected function tearDown(): void #[Test] public function itRegistersFallbackHookWhenWordPressHookApiIsUnavailable(): void { - if (function_exists('add_action')) { - self::markTestSkipped('WordPress hook API is already available.'); - } - - (new WordPressHookRegistrar())->register(__METHOD__, 13); + $hookApi = new RecordingWordPressHookApi(available: false, loadable: false); + (new WordPressHookRegistrar($hookApi))->register(__METHOD__, 13); self::assertSame( [ diff --git a/tests/phpunit/Unit/IO/RequestFilesTest.php b/tests/phpunit/Unit/IO/RequestFilesTest.php new file mode 100644 index 0000000..5ad8249 --- /dev/null +++ b/tests/phpunit/Unit/IO/RequestFilesTest.php @@ -0,0 +1,54 @@ + 'first'], (new JsonFileReader())->read($file)); + $count = $snapshot->observations(); + file_put_contents($file, '{"value":"other"}'); + self::assertSame(['value' => 'first'], (new JsonFileReader())->read($file)); + self::assertSame($count, $snapshot->observations()); + self::assertNull($snapshot->info($directory . '/missing')['canonical']); + $count = $snapshot->observations(); + self::assertNull($snapshot->info($directory . '/missing')['canonical']); + self::assertSame($count, $snapshot->observations()); + RequestFiles::reset(); + self::assertSame(['value' => 'other'], (new JsonFileReader())->read($file)); + self::assertTrue((new FilesystemPathPolicy([$directory]))->allowsPath($file)); + self::assertFalse((new FilesystemPathPolicy([$directory . '/other']))->allowsPath($file)); + } finally { + unlink($file); + rmdir($directory); + } + } + + public function testSuppliedIntegrityHasValidatedDigestBytesAndCrossorigin(): void + { + $asset = new Script('app', 'https://cdn.example.test/app.js'); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encode fixture SRI digest, never executable source. + $hash = 'sha384-' . base64_encode(hash('sha384', 'build bytes', true)); + IntegrityMetadata::apply($asset, $hash); + self::assertSame($hash, $asset->attributes()['integrity']); + self::assertSame('anonymous', $asset->attributes()['crossorigin']); + $this->expectException(\SymPress\Assets\Exception\InvalidResourceException::class); + IntegrityMetadata::apply($asset, 'sha384-not-a-digest'); + } +} diff --git a/tests/phpunit/Unit/Loader/WebpackManifestLoaderTest.php b/tests/phpunit/Unit/Loader/WebpackManifestLoaderTest.php index bd5d05c..b0a1042 100644 --- a/tests/phpunit/Unit/Loader/WebpackManifestLoaderTest.php +++ b/tests/phpunit/Unit/Loader/WebpackManifestLoaderTest.php @@ -50,6 +50,17 @@ public function testLoadFromManifest( static::assertInstanceOf($expectedClass, $asset); } + public function testBuildIntegrityMetadataPropagatesThroughManifestLoader(): void + { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Trusted fixture SRI bytes, never executable content. + $hash = 'sha256-' . base64_encode(hash('sha256', 'built bytes', true)); + $json = json_encode(['secure' => ['filePath' => 'secure.js', 'integrity' => $hash, 'crossorigin' => 'anonymous']], JSON_THROW_ON_ERROR); + $assets = (new WebpackManifestLoader())->load($this->mockManifestJson($json)); + self::assertInstanceOf(Script::class, $assets[0]); + self::assertSame($hash, $assets[0]->attributes()['integrity']); + self::assertSame('anonymous', $assets[0]->attributes()['crossorigin']); + } + /** @test */ public function testLoadFromManifestMultipleAssets(): void { diff --git a/tests/phpunit/Unit/OutputFilter/AsyncStyleOutputFilterTest.php b/tests/phpunit/Unit/OutputFilter/AsyncStyleOutputFilterTest.php index a579732..d1c2bdf 100644 --- a/tests/phpunit/Unit/OutputFilter/AsyncStyleOutputFilterTest.php +++ b/tests/phpunit/Unit/OutputFilter/AsyncStyleOutputFilterTest.php @@ -18,6 +18,14 @@ public function testBasic(): void static::assertInstanceOf(AssetOutputFilter::class, new AsyncStyleOutputFilter()); } + public function testMissingNonceKeepsStylesheetWithoutInlineHandlers(): void + { + Monkey\Functions\when('apply_filters')->justReturn(null); + $asset = \Mockery::mock(FilterAwareAsset::class); + $html = ''; + self::assertSame($html, (new AsyncStyleOutputFilter())($html, $asset)); + } + /** @test */ public function testRender(): void { @@ -27,6 +35,7 @@ public function testRender(): void $input = ''; + Monkey\Functions\when('apply_filters')->justReturn('nonce-test'); Monkey\Functions\when('esc_url')->returnArg(); Monkey\Functions\when('esc_attr')->returnArg(); @@ -47,6 +56,7 @@ public function testRender(): void static::assertIsString($preloadTag); static::assertStringNotContainsString('id="app-css"', $preloadTag); static::assertStringContainsString("", $output); - static::assertStringNotContainsString('";'); @@ -154,7 +164,7 @@ public function testInlineScriptEscapesClosingScriptSequence(): void ); static::assertStringNotContainsString('', $result); } /** @test */