Skip to content

Commit 7d24f46

Browse files
committed
[Integrity] Add integrity and crossorigin to preload Link headers
Without them the browser discards the SRI-guarded preload as an integrity mismatch and re-downloads the asset.
1 parent 7531883 commit 7d24f46

2 files changed

Lines changed: 95 additions & 7 deletions

File tree

src/Asset/TagRenderer.php

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,15 @@ public function renderScriptTags(string $entryName, ?string $packageName = null,
7575
$tagAttributes = ['rel' => 'modulepreload', 'href' => $url];
7676
$this->applyIntegrity($tagAttributes, $reference, $integrity);
7777
$tags[] = \sprintf('<link %s>', $this->attributes($tagAttributes));
78-
$this->preload($url, 'modulepreload');
78+
$this->preload($url, 'modulepreload', null, $reference, $integrity);
7979
}
8080

8181
foreach ($lookup->getJavaScriptFiles($entryName) as $reference) {
8282
$url = $this->url($reference, $packageName);
8383
$tagAttributes = ['src' => $url, 'type' => 'module'] + $attributes + $this->scriptAttributes;
8484
$this->applyIntegrity($tagAttributes, $reference, $integrity);
8585
$tags[] = \sprintf('<script %s></script>', $this->attributes($tagAttributes));
86-
$this->preload($url, 'preload', 'script');
86+
$this->preload($url, 'preload', 'script', $reference, $integrity);
8787
}
8888

8989
return implode('', $tags);
@@ -103,7 +103,7 @@ public function renderLinkTags(string $entryName, ?string $packageName = null, ?
103103
$tagAttributes = ['rel' => 'stylesheet', 'href' => $url] + $attributes + $this->linkAttributes;
104104
$this->applyIntegrity($tagAttributes, $reference, $integrity);
105105
$tags[] = \sprintf('<link %s>', $this->attributes($tagAttributes));
106-
$this->preload($url, 'preload', 'style');
106+
$this->preload($url, 'preload', 'style', $reference, $integrity);
107107
}
108108

109109
return implode('', $tags);
@@ -161,7 +161,10 @@ private function url(string $reference, ?string $packageName): string
161161
return $this->packages->getUrl($reference, $packageName ?? $this->defaultPackage);
162162
}
163163

164-
private function preload(string $url, string $rel, ?string $as = null): void
164+
/**
165+
* @param array<string, string> $integrity
166+
*/
167+
private function preload(string $url, string $rel, ?string $as, string $reference, array $integrity): void
165168
{
166169
if (!$this->preload || null === $this->requestStack || !class_exists(GenericLinkProvider::class)) {
167170
return;
@@ -176,6 +179,11 @@ private function preload(string $url, string $rel, ?string $as = null): void
176179
if (null !== $as) {
177180
$link = $link->withAttribute('as', $as);
178181
}
182+
// Mirror the tag's SRI onto the preload, or the browser discards the preloaded response as a mismatch.
183+
[$hash, $crossorigin] = $this->integrityFor($reference, $integrity);
184+
if (null !== $hash) {
185+
$link = $link->withAttribute('integrity', $hash)->withAttribute('crossorigin', $crossorigin);
186+
}
179187

180188
$linkProvider = $request->attributes->get('_links');
181189
if (!$linkProvider instanceof GenericLinkProvider) {
@@ -190,11 +198,29 @@ private function preload(string $url, string $rel, ?string $as = null): void
190198
*/
191199
private function applyIntegrity(array &$attributes, string $reference, array $integrity): void
192200
{
193-
if (!isset($integrity[$reference])) {
201+
[$hash, $crossorigin] = $this->integrityFor($reference, $integrity);
202+
if (null === $hash) {
194203
return;
195204
}
196-
$attributes['integrity'] = $integrity[$reference];
197-
$attributes['crossorigin'] = false === $this->crossorigin ? 'anonymous' : $this->crossorigin;
205+
$attributes['integrity'] = $hash;
206+
$attributes['crossorigin'] = $crossorigin;
207+
}
208+
209+
/**
210+
* Resolves the SRI hash + crossorigin for a reference, so a tag and its preload Link derive them
211+
* from one place and can never drift.
212+
*
213+
* @param array<string, string> $integrity
214+
*
215+
* @return array{0: ?string, 1: string}
216+
*/
217+
private function integrityFor(string $reference, array $integrity): array
218+
{
219+
if (!isset($integrity[$reference])) {
220+
return [null, ''];
221+
}
222+
223+
return [$integrity[$reference], false === $this->crossorigin ? 'anonymous' : $this->crossorigin];
198224
}
199225

200226
/**

tests/Asset/TagRendererTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,68 @@ public function testRegistersPreloadLinksOnTheRequestWhenWebLinkIsAvailable()
279279
$this->assertSame('style', $byHref['/build/app-c3.css']['as']);
280280
}
281281

282+
public function testPreloadLinksCarryIntegrityAndCrossoriginWhenPresent()
283+
{
284+
$stack = new RequestStack();
285+
$stack->push($request = new Request());
286+
287+
$renderer = $this->renderer(
288+
js: ['build/app-a1b2.js'],
289+
css: ['build/app-c3.css'],
290+
preload: ['build/shared-e5.js'],
291+
integrity: [
292+
'build/app-a1b2.js' => 'sha384-JS',
293+
'build/app-c3.css' => 'sha384-CSS',
294+
'build/shared-e5.js' => 'sha384-SHARED',
295+
],
296+
requestStack: $stack,
297+
);
298+
$renderer->renderScriptTags('app');
299+
$renderer->renderLinkTags('app');
300+
301+
$byHref = [];
302+
foreach ($request->attributes->get('_links')->getLinks() as $link) {
303+
$byHref[$link->getHref()] = $link->getAttributes();
304+
}
305+
306+
$this->assertSame('sha384-SHARED', $byHref['/build/shared-e5.js']['integrity']);
307+
$this->assertSame('anonymous', $byHref['/build/shared-e5.js']['crossorigin']);
308+
$this->assertSame('sha384-JS', $byHref['/build/app-a1b2.js']['integrity']);
309+
$this->assertSame('anonymous', $byHref['/build/app-a1b2.js']['crossorigin']);
310+
$this->assertSame('sha384-CSS', $byHref['/build/app-c3.css']['integrity']);
311+
$this->assertSame('anonymous', $byHref['/build/app-c3.css']['crossorigin']);
312+
}
313+
314+
public function testPreloadLinksHaveNoIntegrityWhenTheAssetIsNotHashed()
315+
{
316+
$stack = new RequestStack();
317+
$stack->push($request = new Request());
318+
319+
$this->renderer(js: ['build/app-a1b2.js'], requestStack: $stack)->renderScriptTags('app');
320+
321+
foreach ($request->attributes->get('_links')->getLinks() as $link) {
322+
$this->assertArrayNotHasKey('integrity', $link->getAttributes());
323+
$this->assertArrayNotHasKey('crossorigin', $link->getAttributes());
324+
}
325+
}
326+
327+
public function testPreloadLinksUseTheConfiguredCrossorigin()
328+
{
329+
$stack = new RequestStack();
330+
$stack->push($request = new Request());
331+
332+
$this->renderer(
333+
js: ['build/app-a1b2.js'],
334+
integrity: ['build/app-a1b2.js' => 'sha384-JS'],
335+
crossorigin: 'use-credentials',
336+
requestStack: $stack,
337+
)->renderScriptTags('app');
338+
339+
foreach ($request->attributes->get('_links')->getLinks() as $link) {
340+
$this->assertSame('use-credentials', $link->getAttributes()['crossorigin']);
341+
}
342+
}
343+
282344
public function testDoesNotRegisterLinksWithoutACurrentRequest()
283345
{
284346
$renderer = $this->renderer(js: ['build/app-a1b2.js'], requestStack: new RequestStack());

0 commit comments

Comments
 (0)