Skip to content

Commit 3e2f20d

Browse files
feat(fonts): csp 2 (#14275)
* feat(fonts): csp * feat: unit test * feat: fixture test * chore: changeset * fix * feat: wip * feat: runtime api * feat: jsdocs * feat: tests * feat: changeset * chore: add error msg * fix: update hash * chore: 2 changesets * feat: update getOrigins to getCspResources * feat: deduplication stuff * fix: test * feat: feedback * fix: types * fix: netlify context type * feat(netlify): dev context * Discard changes to packages/integrations/netlify/src/index.ts * wip * wip * wip * wip * fix: test * feat: add unit test * chore: clean
1 parent 24b04c1 commit 3e2f20d

File tree

24 files changed

+321
-34
lines changed

24 files changed

+321
-34
lines changed

.changeset/legal-trees-retire.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Adds support for experimental CSP when using experimental fonts
6+
7+
Experimental fonts now integrate well with experimental CSP by injecting hashes for the styles it generates, as well as `font-src` directives.
8+
9+
No action is required to benefit from it.

packages/astro/src/assets/fonts/definitions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface UrlProxy {
6363

6464
export interface UrlResolver {
6565
resolve: (hash: string) => string;
66+
getCspResources: () => Array<string>;
6667
}
6768

6869
export interface UrlProxyContentResolver {

packages/astro/src/assets/fonts/implementations/url-resolver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import { getAssetsPrefix } from '../../utils/getAssetsPrefix.js';
44
import type { UrlResolver } from '../definitions.js';
55

66
export function createDevUrlResolver({ base }: { base: string }): UrlResolver {
7+
let resolved = false;
78
return {
89
resolve(hash) {
10+
resolved ||= true;
911
return prependForwardSlash(joinPaths(base, hash));
1012
},
13+
getCspResources() {
14+
return resolved ? ["'self'"] : [];
15+
},
1116
};
1217
}
1318

@@ -18,13 +23,19 @@ export function createBuildUrlResolver({
1823
base: string;
1924
assetsPrefix: AssetsPrefix;
2025
}): UrlResolver {
26+
const resources = new Set<string>();
2127
return {
2228
resolve(hash) {
2329
const prefix = assetsPrefix ? getAssetsPrefix(fileExtension(hash), assetsPrefix) : undefined;
2430
if (prefix) {
31+
resources.add(prefix);
2532
return joinPaths(prefix, base, hash);
2633
}
34+
resources.add("'self'");
2735
return prependForwardSlash(joinPaths(base, hash));
2836
},
37+
getCspResources() {
38+
return Array.from(resources);
39+
},
2940
};
3041
}

packages/astro/src/assets/fonts/vite-plugin-fonts.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises';
33
import { isAbsolute } from 'node:path';
44
import { fileURLToPath } from 'node:url';
55
import type { Plugin } from 'vite';
6+
import { getAlgorithm, shouldTrackCspHashes } from '../../core/csp/common.js';
7+
import { generateCspDigest } from '../../core/encryption.js';
68
import { collectErrorMetadata } from '../../core/errors/dev/utils.js';
79
import { AstroError, AstroErrorData, isAstroError } from '../../core/errors/index.js';
810
import type { Logger } from '../../core/logger/core.js';
@@ -56,7 +58,7 @@ interface Options {
5658
}
5759

5860
export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
59-
if (!settings.config.experimental.fonts) {
61+
if (sync || !settings.config.experimental.fonts) {
6062
// This is required because the virtual module may be imported as
6163
// a side effect
6264
// TODO: remove once fonts are stabilized
@@ -171,6 +173,20 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
171173
// to avoid locking memory
172174
fontFileDataMap = res.fontFileDataMap;
173175
consumableMap = res.consumableMap;
176+
177+
// Handle CSP
178+
if (shouldTrackCspHashes(settings.config.experimental.csp)) {
179+
const algorithm = getAlgorithm(settings.config.experimental.csp);
180+
181+
// Generate a hash for each style we generate
182+
for (const { css } of consumableMap.values()) {
183+
settings.injectedCsp.styleHashes.push(await generateCspDigest(css, algorithm));
184+
}
185+
const resources = urlResolver.getCspResources();
186+
for (const resource of resources) {
187+
settings.injectedCsp.fontResources.add(resource);
188+
}
189+
}
174190
}
175191

176192
return {
@@ -271,7 +287,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
271287
}
272288
},
273289
async buildEnd() {
274-
if (sync || settings.config.experimental.fonts!.length === 0) {
290+
if (settings.config.experimental.fonts!.length === 0) {
275291
cleanup();
276292
return;
277293
}

packages/astro/src/core/build/generate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ async function createBuildManifest(
691691
];
692692
const styleHashes = [
693693
...getStyleHashes(settings.config.experimental.csp),
694+
...settings.injectedCsp.styleHashes,
694695
...(await trackStyleHashes(internals, settings, algorithm)),
695696
];
696697

@@ -703,7 +704,7 @@ async function createBuildManifest(
703704
scriptHashes,
704705
scriptResources: getScriptResources(settings.config.experimental.csp),
705706
algorithm,
706-
directives: getDirectives(settings.config.experimental.csp),
707+
directives: getDirectives(settings),
707708
isStrictDynamic: getStrictDynamic(settings.config.experimental.csp),
708709
};
709710
}

packages/astro/src/core/build/plugins/plugin-manifest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ async function buildManifest(
323323
];
324324
const styleHashes = [
325325
...getStyleHashes(settings.config.experimental.csp),
326+
...settings.injectedCsp.styleHashes,
326327
...(await trackStyleHashes(internals, settings, algorithm)),
327328
];
328329

@@ -335,7 +336,7 @@ async function buildManifest(
335336
styleHashes,
336337
styleResources: getStyleResources(settings.config.experimental.csp),
337338
algorithm,
338-
directives: getDirectives(settings.config.experimental.csp),
339+
directives: getDirectives(settings),
339340
isStrictDynamic: getStrictDynamic(settings.config.experimental.csp),
340341
};
341342
}

packages/astro/src/core/config/settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
150150
latestAstroVersion: undefined, // Will be set later if applicable when the dev server starts
151151
injectedTypes: [],
152152
buildOutput: undefined,
153+
injectedCsp: {
154+
fontResources: new Set(),
155+
styleHashes: [],
156+
},
153157
};
154158
}
155159

packages/astro/src/core/csp/common.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,39 @@ export function getStyleResources(csp: EnabledCsp): string[] {
5151
return csp.styleDirective?.resources ?? [];
5252
}
5353

54-
export function getDirectives(csp: EnabledCsp): CspDirective[] {
55-
if (csp === true) {
54+
// Unlike other helpers like getStyleResources, getDirectives has more logic
55+
// because it has to collect and deduplicate font resources from both the user
56+
// config and the vite plugin for fonts
57+
export function getDirectives(settings: AstroSettings): CspDirective[] {
58+
const { csp } = settings.config.experimental;
59+
if (!shouldTrackCspHashes(csp)) {
5660
return [];
5761
}
58-
return csp.directives ?? [];
62+
const userDirectives = csp === true ? [] : [...(csp.directives ?? [])];
63+
const fontResources = Array.from(settings.injectedCsp.fontResources.values());
64+
65+
if (fontResources.length === 0) {
66+
// If no font resources, just return user directives
67+
return userDirectives;
68+
}
69+
70+
const fontSrcIndex = userDirectives.findIndex((e) => e.startsWith('font-src'));
71+
if (fontSrcIndex === -1) {
72+
// Add new font-src directive
73+
return [...userDirectives, `font-src ${fontResources.join(' ')}`];
74+
}
75+
76+
// Merge and deduplicate font-src resources
77+
const existing = userDirectives[fontSrcIndex]
78+
// split spaces
79+
.split(/\s+/)
80+
// ignore first match as it's the directive name
81+
.slice(1)
82+
// Avoid duplicated spaces
83+
.filter(Boolean);
84+
const merged = Array.from(new Set([...existing, ...fontResources]));
85+
userDirectives[fontSrcIndex] = `font-src ${merged.join(' ')}`;
86+
return userDirectives;
5987
}
6088

6189
export function getStrictDynamic(csp: EnabledCsp): boolean {

packages/astro/src/core/render-context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ export class RenderContext {
445445
renderContext.result?.styleHashes.push(hash);
446446
},
447447
insertScriptHash(hash) {
448-
if (!!pipeline.manifest.csp === false) {
448+
if (!pipeline.manifest.csp) {
449449
throw new AstroError(CspNotEnabled);
450450
}
451451
renderContext.result?.scriptHashes.push(hash);
@@ -710,7 +710,7 @@ export class RenderContext {
710710
renderContext.result?.styleHashes.push(hash);
711711
},
712712
insertScriptHash(hash) {
713-
if (!!pipeline.manifest.csp === false) {
713+
if (!pipeline.manifest.csp) {
714714
throw new AstroError(CspNotEnabled);
715715
}
716716
renderContext.result?.scriptHashes.push(hash);

packages/astro/src/runtime/server/render/csp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function renderCspContent(result: SSRResult): string {
2020
finalScriptHashes.add(`'${scriptHash}'`);
2121
}
2222

23-
let directives = '';
23+
let directives;
2424
if (result.directives.length > 0) {
2525
directives = result.directives.join(';') + ';';
2626
}
@@ -38,5 +38,5 @@ export function renderCspContent(result: SSRResult): string {
3838
const strictDynamic = result.isStrictDynamic ? ` 'strict-dynamic'` : '';
3939
const scriptSrc = `script-src ${scriptResources} ${Array.from(finalScriptHashes).join(' ')}${strictDynamic};`;
4040
const styleSrc = `style-src ${styleResources} ${Array.from(finalStyleHashes).join(' ')};`;
41-
return `${directives} ${scriptSrc} ${styleSrc}`;
41+
return [directives, scriptSrc, styleSrc].filter(Boolean).join(' ');
4242
}

0 commit comments

Comments
 (0)