Skip to content

Commit 3ecf45e

Browse files
committed
fix(svg): track inline styles for CSP
1 parent 07c1002 commit 3ecf45e

7 files changed

Lines changed: 123 additions & 12 deletions

File tree

.changeset/jolly-dragons-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes an issue where `<style>` tags inside SVG components weren't correctly tracked when enabling CSP.

packages/astro/src/assets/runtime.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,41 @@
1+
import { generateCspDigest } from '../core/encryption.js';
12
import {
23
createComponent,
34
render,
45
spreadAttributes,
56
unescapeHTML,
67
} from '../runtime/server/index.js';
8+
import type { SSRResult } from '../types/public/internal.js';
79
import type { ImageMetadata } from './types.js';
810

911
export interface SvgComponentProps {
1012
meta: ImageMetadata;
1113
attributes: Record<string, string>;
1214
children: string;
15+
styles: string[];
1316
}
1417

15-
export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
16-
const Component = createComponent((_, props) => {
17-
const normalizedProps = normalizeProps(attributes, props);
18+
export function createSvgComponent({ meta, attributes, children, styles }: SvgComponentProps) {
19+
const hasStyles = styles.length > 0;
1820

19-
return render`<svg${spreadAttributes(normalizedProps)}>${unescapeHTML(children)}</svg>`;
21+
const Component = createComponent({
22+
async factory(result: SSRResult, props: Record<string, any>) {
23+
const normalizedProps = normalizeProps(attributes, props);
24+
25+
// When CSP is enabled, hash each SVG <style> so the browser allows them.
26+
// The styles stay inside the <svg> where they belong — we only need the
27+
// hashes registered before the CSP meta tag is emitted in the <head>.
28+
// propagation: 'self' ensures init() runs during bufferHeadContent().
29+
if (hasStyles && result.cspDestination) {
30+
for (const style of styles) {
31+
const hash = await generateCspDigest(style, result.cspAlgorithm);
32+
result._metadata.extraStyleHashes.push(hash);
33+
}
34+
}
35+
36+
return render`<svg${spreadAttributes(normalizedProps)}>${unescapeHTML(children)}</svg>`;
37+
},
38+
propagation: hasStyles ? 'self' : 'none',
2039
});
2140

2241
if (import.meta.env.DEV) {

packages/astro/src/assets/utils/svg.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { optimize } from 'svgo';
2-
import { parse, renderSync } from 'ultrahtml';
2+
import { ELEMENT_NODE, TEXT_NODE, parse, renderSync } from 'ultrahtml';
33
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
44
import type { AstroConfig } from '../../types/public/config.js';
55
import type { SvgComponentProps } from '../runtime.js';
@@ -33,15 +33,29 @@ function parseSvg({
3333
}
3434
const root = parse(processedContents);
3535
const svgNode = root.children.find(
36-
({ name, type }: { name: string; type: number }) => type === 1 /* Element */ && name === 'svg',
36+
({ name, type }: { name: string; type: number }) => type === ELEMENT_NODE && name === 'svg',
3737
);
3838
if (!svgNode) {
3939
throw new Error('SVG file does not contain an <svg> element');
4040
}
4141
const { attributes, children } = svgNode;
4242
const body = renderSync({ ...root, children });
4343

44-
return { attributes, body };
44+
// Collect text content of <style> elements for head propagation and CSP hashing
45+
const styles: string[] = [];
46+
for (const child of children) {
47+
if (child.type === ELEMENT_NODE && child.name === 'style') {
48+
const textContent = child.children
49+
?.filter((c: { type: number }) => c.type === TEXT_NODE)
50+
.map((c: { value: string }) => c.value)
51+
.join('');
52+
if (textContent) {
53+
styles.push(textContent);
54+
}
55+
}
56+
}
57+
58+
return { attributes, body, styles };
4559
}
4660

4761
export function makeSvgComponent(
@@ -50,7 +64,11 @@ export function makeSvgComponent(
5064
svgoConfig: AstroConfig['experimental']['svgo'],
5165
): string {
5266
const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
53-
const { attributes, body: children } = parseSvg({
67+
const {
68+
attributes,
69+
body: children,
70+
styles,
71+
} = parseSvg({
5472
path: meta.fsPath,
5573
contents: file,
5674
svgoConfig,
@@ -59,6 +77,7 @@ export function makeSvgComponent(
5977
meta,
6078
attributes: dropAttributes(attributes),
6179
children,
80+
styles,
6281
};
6382

6483
return `import { createSvgComponent } from 'astro/assets/runtime';
@@ -74,12 +93,16 @@ export function parseSvgComponentData(
7493
meta: ImageMetadata,
7594
contents: Buffer | string,
7695
svgoConfig: AstroConfig['experimental']['svgo'],
77-
): { attributes: Record<string, string>; children: string } {
96+
): { attributes: Record<string, string>; children: string; styles: string[] } {
7897
const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
79-
const { attributes, body: children } = parseSvg({
98+
const {
99+
attributes,
100+
body: children,
101+
styles,
102+
} = parseSvg({
80103
path: meta.fsPath,
81104
contents: file,
82105
svgoConfig,
83106
});
84-
return { attributes: dropAttributes(attributes), children };
107+
return { attributes: dropAttributes(attributes), children, styles };
85108
}

packages/astro/src/content/runtime.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,13 @@ function updateImageReferencesInData<T extends Record<string, unknown>>(
521521
return;
522522
}
523523
const imported = imageAssetMap?.get(id) as
524-
| (ImageMetadata & { __svgData?: { attributes: Record<string, string>; children: string } })
524+
| (ImageMetadata & {
525+
__svgData?: {
526+
attributes: Record<string, string>;
527+
children: string;
528+
styles: string[];
529+
};
530+
})
525531
| undefined;
526532
if (imported) {
527533
if (imported.__svgData) {

packages/astro/test/csp.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,43 @@ describe('CSP', () => {
121121
assert.ok(styleMatches && styleMatches.length > 0, 'CSP should contain style hashes');
122122
});
123123

124+
it('should generate hashes for SVG component inline styles', async () => {
125+
fixture = await loadFixture({
126+
root: './fixtures/csp/',
127+
outDir: './dist/csp-svg',
128+
});
129+
await fixture.build();
130+
const html = await fixture.readFile('/svg/index.html');
131+
const $ = cheerio.load(html);
132+
133+
// The SVG should be rendered inline with its <style> tag intact
134+
const svg = $('svg');
135+
assert.ok(svg.length > 0, 'SVG should be rendered inline');
136+
const svgStyle = $('svg style');
137+
assert.ok(svgStyle.length > 0, 'SVG should contain its <style> element');
138+
assert.ok(
139+
svgStyle.text().includes('.square{fill: red}'),
140+
'SVG style should have original content',
141+
);
142+
143+
// The style should NOT be duplicated in the <head> — it stays inside the <svg>
144+
const headStyles = $('head style');
145+
const headHasSvgStyle = headStyles
146+
.toArray()
147+
.some((el) => $(el).text().includes('.square{fill: red}'));
148+
assert.ok(!headHasSvgStyle, 'SVG style should not be duplicated in <head>');
149+
150+
// The CSP meta tag should contain a hash for the SVG's inline style
151+
const meta = $('meta[http-equiv="Content-Security-Policy"]');
152+
const cspContent = meta.attr('content').toString();
153+
assert.ok(cspContent.includes('style-src'), 'CSP should have style-src directive');
154+
// sha256 hash of ".square{fill: red}"
155+
assert.ok(
156+
cspContent.includes("'sha256-TFjYo91aZcH4Kex6qdJUFz/POAVYu5H/OgkpRfHpLfw='"),
157+
'CSP should contain the hash of the SVG inline style',
158+
);
159+
});
160+
124161
it('should return CSP header inside a hook', async () => {
125162
let routeToHeaders;
126163
fixture = await loadFixture({
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
import Icon from "../assets/test.svg"
3+
---
4+
5+
<html lang="en">
6+
<head>
7+
<meta charset="utf-8"/>
8+
<meta name="viewport" content="width=device-width"/>
9+
<title>Image CSP Test</title>
10+
</head>
11+
<body>
12+
<main>
13+
<h1>Image with layout</h1>
14+
<Icon />
15+
</main>
16+
</body>
17+
</html>

0 commit comments

Comments
 (0)