From d50ee6257823fa7d36ec3ce4db6810151259cbc7 Mon Sep 17 00:00:00 2001 From: Tim MacDonald Date: Wed, 27 Jul 2022 14:41:38 +1000 Subject: [PATCH] support CSP nonce with Vite --- src/Illuminate/Foundation/Vite.php | 270 ++++++++++++++++++++-- src/Illuminate/Support/Facades/Facade.php | 2 + tests/Foundation/FoundationViteTest.php | 231 +++++++++++++++++- 3 files changed, 482 insertions(+), 21 deletions(-) diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 7e641ab7b606..6b96e3ab8ee6 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -3,11 +3,117 @@ namespace Illuminate\Foundation; use Exception; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; class Vite { + /** + * The Content Security Policy nonce to apply to all generated tags. + * + * @var string|null + */ + protected static $nonce; + + /** + * The key to check for integrity hashes within the manifest. + * + * @var string|bool + */ + protected static $integrityKey = 'integrity'; + + /** + * The script tag attributes resolvers. + * + * @var array + */ + protected static $scriptTagAttributesResolvers = []; + + /** + * The style tag attributes resolvers. + * + * @var array + */ + protected static $styleTagAttributesResolvers = []; + + /** + * Generate or set a Content Security Policy nonce to apply to all generated tags. + * + * @param ?string $nonce + * @return string + */ + public static function useCspNonce($nonce = null) + { + return static::$nonce = $nonce ?? Str::random(40); + } + + /** + * Get the Content Security Policy nonce applied to all generated tags. + * + * @return string|null + */ + public static function cspNonce() + { + return static::$nonce; + } + + /** + * Clear the Content Security Policy nonce. + * + * @return void + */ + public static function withoutCspNonce() + { + static::$nonce = null; + } + + /** + * Use the given key to detect integrity hashes in the manifest. + * + * @param bool|string $key + * @return void + */ + public static function useIntegrityKey($key) + { + static::$integrityKey = $key; + } + + /** + * Use the given callback to resolve attributes for script tags. + * + * @param callable $callback + * return void + */ + public static function useAttributesForScriptTag($callback) + { + static::$scriptTagAttributesResolvers[] = $callback; + } + + /** + * Use the given callback to resolve attributes for script tags. + * + * @param callable $callback + * return void + */ + public static function useAttributesForStyleTag($callback) + { + static::$styleTagAttributesResolvers[] = $callback; + } + + /** + * Clear the attribute resolvers. + * + * @return void + */ + public static function withoutAttributeResolvers() + { + static::$styleTagAttributesResolvers = []; + + static::$scriptTagAttributesResolvers = []; + } + /** * Generate Vite tags for an entrypoint. * @@ -29,6 +135,7 @@ public function __invoke($entrypoints, $buildDirectory = 'build') return new HtmlString( $entrypoints + // TODO: for HMR, should we just pass through an empty array for the chunk? ->map(fn ($entrypoint) => $this->makeTag("{$url}/{$entrypoint}")) ->prepend($this->makeScriptTag("{$url}/@vite/client")) ->join('') @@ -54,21 +161,27 @@ public function __invoke($entrypoints, $buildDirectory = 'build') throw new Exception("Unable to locate file in Vite manifest: {$entrypoint}."); } - $tags->push($this->makeTag(asset("{$buildDirectory}/{$manifest[$entrypoint]['file']}"))); + $tags->push($this->makeTagForChunk( + asset("{$buildDirectory}/{$manifest[$entrypoint]['file']}"), + $manifest[$entrypoint], + $manifest + )); - if (isset($manifest[$entrypoint]['css'])) { - foreach ($manifest[$entrypoint]['css'] as $css) { - $tags->push($this->makeStylesheetTag(asset("{$buildDirectory}/{$css}"))); - } + foreach ($manifest[$entrypoint]['css'] ?? [] as $css) { + $tags->push($this->makeTagForChunk( + asset("{$buildDirectory}/{$css}"), + $manifest[$entrypoint], + $manifest + )); } - if (isset($manifest[$entrypoint]['imports'])) { - foreach ($manifest[$entrypoint]['imports'] as $import) { - if (isset($manifest[$import]['css'])) { - foreach ($manifest[$import]['css'] as $css) { - $tags->push($this->makeStylesheetTag(asset("{$buildDirectory}/{$css}"))); - } - } + foreach ($manifest[$entrypoint]['imports'] ?? [] as $import) { + foreach ($manifest[$import]['css'] ?? [] as $css) { + $tags->push( + $this->makeTagForChunk(asset("{$buildDirectory}/{$css}"), + Collection::make($manifest)->firstWhere('file', $css), + $manifest + )); } } } @@ -108,7 +221,80 @@ public function reactRefresh() } /** - * Generate an appropriate tag for the given URL. + * Make tag for the given chunk. + * + * @param string $url + * @param array $chunk + * @param array $manifest + * @return string + */ + protected function makeTagForChunk($url, $chunk, $manifest) + { + if ( + static::$nonce === null + && ! array_key_exists(static::$integrityKey, $chunk) + && static::$scriptTagAttributesResolvers === [] + && static::$styleTagAttributesResolvers === []) { + return $this->makeTag($url); + } + + if ($this->isCssPath($url)) { + return $this->makeStylesheetTagWithAttributes( + $url, + $this->resolveStyleTagAttributes($url, $chunk, $manifest) + ); + } + + return $this->makeScriptTagWithAttributes( + $url, + $this->resolveScriptTagAttributes($url, $chunk, $manifest) + ); + } + + /** + * Resolve the attributes for the chunks generated script tag. + * + * @param string $url + * @param array $chunk + * @param array $manifest + * @return array + */ + protected function resolveScriptTagAttributes($url, $chunk, $manifest) + { + $attributes = static::$integrityKey + ? ['integrity' => $chunk[static::$integrityKey] ?? false] + : []; + + foreach (static::$scriptTagAttributesResolvers as $resolver) { + $attributes = array_merge($attributes, $resolver($chunk, $manifest, $url)); + } + + return $attributes; + } + + /** + * Resolve the attributes for the chunks generated style tag. + * + * @param string $url + * @param array $chunk + * @param array $manifest + * @return array + */ + protected function resolveStyleTagAttributes($url, $chunk, $manifest) + { + $attributes = static::$integrityKey + ? ['integrity' => $chunk[static::$integrityKey] ?? false] + : []; + + foreach (static::$styleTagAttributesResolvers as $resolver) { + $attributes = array_merge($attributes, $resolver($chunk, $manifest, $url)); + } + + return $attributes; + } + + /** + * Generate an appropriate tag for the given URL in HMR mode. * * @param string $url * @return string @@ -130,18 +316,54 @@ protected function makeTag($url) */ protected function makeScriptTag($url) { - return sprintf('', $url); + return $this->makeScriptTagWithAttributes($url, []); } /** - * Generate a stylesheet tag for the given URL. + * Generate a stylesheet tag for the given URL in HMR mode. * * @param string $url * @return string */ protected function makeStylesheetTag($url) { - return sprintf('', $url); + return $this->makeStylesheetTagWithAttributes($url, []); + } + + /** + * Generate a script tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeScriptTagWithAttributes($url, $attributes) + { + $attributes = $this->parseAttributes(array_merge([ + 'type' => 'module', + 'src' => $url, + 'nonce' => static::$nonce ?? false, + ], $attributes)); + + return ''; + } + + /** + * Generate a link tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeStylesheetTagWithAttributes($url, $attributes) + { + $attributes = $this->parseAttributes(array_merge([ + 'rel' => 'stylesheet', + 'href' => $url, + 'nonce' => static::$nonce ?? false, + ], $attributes)); + + return ''; } /** @@ -154,4 +376,20 @@ protected function isCssPath($path) { return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/', $path) === 1; } + + /** + * Parse the attributes into key="value" strings. + * + * @param array $attributes + * @return array + */ + protected function parseAttributes($attributes) + { + return Collection::make($attributes) + ->reject(fn ($value, $key) => $value === false) + ->flatMap(fn ($value, $key) => $value === true ? [$key] : [$key => $value]) + ->map(fn ($value, $key) => is_int($key) ? $value : $key.'="'.$value.'"') + ->values() + ->all(); + } } diff --git a/src/Illuminate/Support/Facades/Facade.php b/src/Illuminate/Support/Facades/Facade.php index d17e16c7c044..aead786f9871 100755 --- a/src/Illuminate/Support/Facades/Facade.php +++ b/src/Illuminate/Support/Facades/Facade.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Vite; use Illuminate\Support\Arr; use Illuminate\Support\Js; use Illuminate\Support\Str; @@ -293,6 +294,7 @@ public static function defaultAliases() 'URL' => URL::class, 'Validator' => Validator::class, 'View' => View::class, + 'Vite' => Vite::class, ]); } diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php index 4a9c5b61c91e..3604c4af131b 100644 --- a/tests/Foundation/FoundationViteTest.php +++ b/tests/Foundation/FoundationViteTest.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Vite; use Illuminate\Routing\UrlGenerator; +use Illuminate\Support\Str; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -101,15 +102,229 @@ public function testViteHotModuleReplacementWithJsAndCss() ); } - protected function makeViteManifest() + public function testItCanGenerateCspNonceWithHotFile() + { + Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}"); + $this->makeViteHotFile(); + + $nonce = Vite::useCspNonce(); + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame('random-string-with-length:40', $nonce); + $this->assertSame('random-string-with-length:40', Vite::cspNonce()); + $this->assertSame( + '' + .'' + .'', + $result->toHtml() + ); + + Str::createRandomStringsNormally(); + Vite::withoutCspNonce(); + } + + public function testItCanGenerateCspNonceWithManifest() + { + Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}"); + $this->makeViteManifest(); + + $nonce = Vite::useCspNonce(); + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame('random-string-with-length:40', $nonce); + $this->assertSame('random-string-with-length:40', Vite::cspNonce()); + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + Str::createRandomStringsNormally(); + Vite::withoutCspNonce(); + } + + public function testItCanSpecifyCspNonceWithHotFile() + { + $this->makeViteHotFile(); + + $nonce = Vite::useCspNonce('expected-nonce'); + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame('expected-nonce', $nonce); + $this->assertSame('expected-nonce', Vite::cspNonce()); + $this->assertSame( + '' + .'' + .'', + $result->toHtml() + ); + + Vite::withoutCspNonce(); + } + + public function testItCanSpecifyCspNonceWithManifest() + { + $this->makeViteManifest(); + + $nonce = Vite::useCspNonce('expected-nonce'); + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame('expected-nonce', $nonce); + $this->assertSame('expected-nonce', Vite::cspNonce()); + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + Vite::withoutCspNonce(); + } + + public function testItCanInjectIntegrityWhenPresentInManifest() + { + $buildDir = Str::random(); + $this->makeViteManifest([ + 'resources/js/app.js' => [ + 'file' => 'assets/app.versioned.js', + 'integrity' => 'expected-app.js-integrity' + ], + 'resources/css/app.css' => [ + 'file' => 'assets/app.versioned.css', + 'integrity' => 'expected-app.css-integrity' + ], + ], $buildDir); + + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js'], $buildDir); + + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + unlink(public_path("{$buildDir}/manifest.json")); + rmdir(public_path($buildDir)); + } + + public function testItCanInjectIntegrityWhenPresentInManifestForImportedCss() + { + $buildDir = Str::random(); + $this->makeViteManifest([ + "resources/js/app.js" => [ + "file" => "assets/app.versioned.js", + "imports" => [ + "_import.versioned.js" + ], + "integrity" => "expected-app.js-integrity" + ], + "_import.versioned.js" => [ + "file" => "assets/import.versioned.js", + "css" => [ + "assets/imported-css.versioned.css" + ], + "integrity" => "expected-import.js-integrity" + ], + "imported-css.css" => [ + "file" => "assets/imported-css.versioned.css", + "integrity" => "expected-imported-css.css-integrity" + ] + ], $buildDir); + + $result = (new Vite)('resources/js/app.js', $buildDir); + + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + unlink(public_path("{$buildDir}/manifest.json")); + rmdir(public_path($buildDir)); + } + + public function testItCanSpecifyIntegrityKey() + { + $buildDir = Str::random(); + $this->makeViteManifest([ + 'resources/js/app.js' => [ + 'file' => 'assets/app.versioned.js', + 'different-integrity-key' => 'expected-app.js-integrity' + ], + 'resources/css/app.css' => [ + 'file' => 'assets/app.versioned.css', + 'different-integrity-key' => 'expected-app.css-integrity' + ], + ], $buildDir); + Vite::useIntegrityKey('different-integrity-key'); + + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js'], $buildDir); + + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + unlink(public_path("{$buildDir}/manifest.json")); + rmdir(public_path($buildDir)); + Vite::useIntegrityKey('integrity'); + } + + public function testItCanSpecifyArbitraryAttributesPerScriptTag() + { + $this->makeViteManifest(); + Vite::useAttributesForScriptTag(function ($chunk, $manifest, $url) { + return [ + 'crossorigin', + 'data-persistent-across-pages' => 'YES', + 'remove-me' => false, + 'keep-me' => true, + ]; + }); + + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + Vite::withoutAttributeResolvers(); + } + + public function testItCanSpecifyArbitraryAttributesPerStyleTag() + { + $this->makeViteManifest(); + Vite::useAttributesForStyleTag(function ($chunk, $manifest, $url) { + return [ + 'crossorigin', + 'data-persistent-across-pages' => 'YES', + 'remove-me' => false, + 'keep-me' => true, + ]; + }); + + $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']); + + $this->assertSame( + '' + .'', + $result->toHtml() + ); + + Vite::withoutAttributeResolvers(); + } + + protected function makeViteManifest($contents = null, $path = 'build') { app()->singleton('path.public', fn () => __DIR__); - if (! file_exists(public_path('build'))) { - mkdir(public_path('build')); + if (! file_exists(public_path($path))) { + mkdir(public_path($path)); } - $manifest = json_encode([ + $manifest = json_encode($contents ?? [ 'resources/js/app.js' => [ 'file' => 'assets/app.versioned.js', ], @@ -119,6 +334,9 @@ protected function makeViteManifest() 'assets/imported-css.versioned.css', ], ], + 'resources/css/imported-css.css' => [ + 'file' => 'assets/imported-css.versioned.css', + ], 'resources/js/app-with-shared-css.js' => [ 'file' => 'assets/app-with-shared-css.versioned.js', 'imports' => [ @@ -133,9 +351,12 @@ protected function makeViteManifest() 'assets/shared-css.versioned.css', ], ], + 'resources/css/shared-css' => [ + 'file' => 'assets/shared-css.versioned.css', + ] ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - file_put_contents(public_path('build/manifest.json'), $manifest); + file_put_contents(public_path("{$path}/manifest.json"), $manifest); } protected function cleanViteManifest()