Skip to content

Commit a9ac5ed

Browse files
ematipicoflorian-lefebvresarah11918
authored
refactor(csp): change CSP behaviour (#13923)
Co-authored-by: Sarah Rainsberger <[email protected]> Co-authored-by: florian-lefebvre <[email protected]> Co-authored-by: sarah11918 <[email protected]>
1 parent 1cd8c3b commit a9ac5ed

File tree

12 files changed

+141
-35
lines changed

12 files changed

+141
-35
lines changed

.changeset/cold-beans-grow.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
6+
**BREAKING CHANGE to the experimental Content Security Policy (CSP) only**
7+
8+
Changes the behavior of experimental Content Security Policy (CSP) to now serve hashes differently depending on whether or not a page is prerendered:
9+
10+
- Via the `<meta>` element for static pages.
11+
- Via the `Response` header `content-security-policy` for on-demand rendered pages.
12+
13+
This new strategy allows you to add CSP content that is not supported in a `<meta>` element (e.g. `report-uri`, `frame-ancestors`, and sandbox directives) to on-demand rendered pages.
14+
15+
No change to your project code is required as this is an implementation detail. However, this will result in a different HTML output for pages that are rendered on demand. Please check your production site to verify that CSP is working as intended.
16+
17+
To keep up to date with this developing feature, or to leave feedback, visit the [CSP Roadmap proposal](https://github.com/withastro/roadmap/blob/feat/rfc-csp/proposals/0055-csp.md).

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const ALLOWED_DIRECTIVES = [
6363
'upgrade-insecure-requests',
6464
'worker-src',
6565
] as const;
66-
66+
type AllowedDirectives = (typeof ALLOWED_DIRECTIVES)[number];
6767
export type CspDirective = `${AllowedDirectives} ${string}`;
6868

6969
export const allowedDirectivesSchema = z.custom<CspDirective>((value) => {
@@ -74,5 +74,3 @@ export const allowedDirectivesSchema = z.custom<CspDirective>((value) => {
7474
return value.startsWith(allowedValue);
7575
});
7676
});
77-
78-
type AllowedDirectives = (typeof ALLOWED_DIRECTIVES)[number];

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,11 @@ export class RenderContext {
454454
},
455455
} satisfies AstroGlobal['response'];
456456

457+
let cspDestination: 'meta' | 'header' = 'meta';
458+
if (!routeData.prerender) {
459+
cspDestination = 'header';
460+
}
461+
457462
// Create the result object that will be passed into the renderPage function.
458463
// This object starts here as an empty shell (not yet the result) but then
459464
// calling the render() function will populate the object with scripts, styles, etc.
@@ -496,6 +501,7 @@ export class RenderContext {
496501
extraScriptHashes: [],
497502
propagators: new Set(),
498503
},
504+
cspDestination,
499505
shouldInjectCspMetaTags: !!manifest.csp,
500506
cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256',
501507
// The following arrays must be cloned, otherwise they become mutable across routes.

packages/astro/src/integrations/features-validation.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
AdapterSupportsKind,
66
AstroAdapterFeatureMap,
77
} from '../types/public/integrations.js';
8+
import { shouldTrackCspHashes } from '../core/csp/common.js';
89

910
export const AdapterFeatureStability = {
1011
STABLE: 'stable',
@@ -38,6 +39,7 @@ export function validateSupportedFeatures(
3839
i18nDomains = AdapterFeatureStability.UNSUPPORTED,
3940
envGetSecret = AdapterFeatureStability.UNSUPPORTED,
4041
sharpImageService = AdapterFeatureStability.UNSUPPORTED,
42+
cspHeader = AdapterFeatureStability.UNSUPPORTED,
4143
} = featureMap;
4244
const validationResult: ValidationResult = {};
4345

@@ -93,6 +95,17 @@ export function validateSupportedFeatures(
9395
() => settings.config?.image?.service?.entrypoint === 'astro/assets/services/sharp',
9496
);
9597

98+
validationResult.cspHeader = validateSupportKind(
99+
cspHeader,
100+
adapterName,
101+
logger,
102+
'cspHeader',
103+
() =>
104+
settings?.config?.experimental?.csp
105+
? shouldTrackCspHashes(settings.config.experimental.csp)
106+
: false,
107+
);
108+
96109
return validationResult;
97110
}
98111

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function renderAllHeadContent(result: SSRResult) {
5252
}
5353
}
5454

55-
if (result.shouldInjectCspMetaTags) {
55+
if (result.cspDestination === 'meta') {
5656
content += renderElement(
5757
'meta',
5858
{

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { encoder } from './common.js';
55
import { type NonAstroPageComponent, renderComponentToString } from './component.js';
66
import type { AstroComponentFactory } from './index.js';
77
import { isDeno, isNode } from './util.js';
8+
import { renderCspContent } from './csp.js';
89

910
export async function renderPage(
1011
result: SSRResult,
@@ -31,12 +32,15 @@ export async function renderPage(
3132
);
3233

3334
const bytes = encoder.encode(str);
34-
35+
const headers = new Headers([
36+
['Content-Type', 'text/html'],
37+
['Content-Length', bytes.byteLength.toString()],
38+
]);
39+
if (result.cspDestination === 'header') {
40+
headers.set('content-security-policy', renderCspContent(result));
41+
}
3542
return new Response(bytes, {
36-
headers: new Headers([
37-
['Content-Type', 'text/html'],
38-
['Content-Length', bytes.byteLength.toString()],
39-
]),
43+
headers,
4044
});
4145
}
4246

@@ -74,6 +78,9 @@ export async function renderPage(
7478
// Create final response from body
7579
const init = result.response;
7680
const headers = new Headers(init.headers);
81+
if (result.shouldInjectCspMetaTags && result.cspDestination === 'header') {
82+
headers.set('content-security-policy', renderCspContent(result));
83+
}
7784
// For non-streaming, convert string to byte array to calculate Content-Length
7885
if (!streaming && typeof body === 'string') {
7986
body = encoder.encode(body);

packages/astro/src/runtime/server/render/server-islands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ let response = await fetch('${serverIslandUrl}', {
136136

137137
const content = `${method}replaceServerIsland('${hostId}', response);`;
138138

139-
if (this.result.shouldInjectCspMetaTags) {
139+
if (this.result.cspDestination) {
140140
this.result._metadata.extraScriptHashes.push(
141141
await generateCspDigest(SERVER_ISLAND_REPLACER, this.result.cspAlgorithm),
142142
);

packages/astro/src/types/public/integrations.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export interface AstroAdapter {
108108

109109
export type AstroAdapterFeatureMap = {
110110
/**
111-
* The adapter is able serve static pages
111+
* The adapter is able to serve static pages
112112
*/
113113
staticOutput?: AdapterSupport;
114114

@@ -136,6 +136,12 @@ export type AstroAdapterFeatureMap = {
136136
* The adapter supports image transformation using the built-in Sharp image service
137137
*/
138138
sharpImageService?: AdapterSupport;
139+
140+
/**
141+
* The adapter is able to provide CSP hashes using the Response header `Content-Security-Policy`. Either via hosting configuration
142+
* for static pages or at runtime using `Response` headers for dynamic pages.
143+
*/
144+
cspHeader?: AdapterSupport;
139145
};
140146

141147
/**
@@ -197,21 +203,26 @@ export interface BaseIntegrationHooks {
197203
address: AddressInfo;
198204
logger: AstroIntegrationLogger;
199205
}) => void | Promise<void>;
200-
'astro:server:done': (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
206+
'astro:server:done': (options: {
207+
logger: AstroIntegrationLogger;
208+
}) => void | Promise<void>;
201209
'astro:build:ssr': (options: {
202210
manifest: SerializedSSRManifest;
203211
/**
204212
* This maps a {@link RouteData} to an {@link URL}, this URL represents
205213
* the physical file you should import.
206214
*/
215+
// TODO: Change in Astro 6.0
207216
entryPoints: Map<IntegrationRouteData, URL>;
208217
/**
209218
* File path of the emitted middleware
210219
*/
211220
middlewareEntryPoint: URL | undefined;
212221
logger: AstroIntegrationLogger;
213222
}) => void | Promise<void>;
214-
'astro:build:start': (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
223+
'astro:build:start': (options: {
224+
logger: AstroIntegrationLogger;
225+
}) => void | Promise<void>;
215226
'astro:build:setup': (options: {
216227
vite: ViteInlineConfig;
217228
pages: Map<string, PageBuildData>;

packages/astro/src/types/public/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ export interface SSRResult {
250250
/**
251251
* Whether Astro should inject the CSP <meta> tag into the head of the component.
252252
*/
253+
cspDestination: 'header' | 'meta';
253254
shouldInjectCspMetaTags: boolean;
254255
cspAlgorithm: SSRManifestCSP['algorithm'];
255256
scriptHashes: SSRManifestCSP['scriptHashes'];

packages/astro/test/csp.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,34 @@ describe('CSP', () => {
268268
const meta = $('meta[http-equiv="Content-Security-Policy"]');
269269
assert.ok(meta.attr('content').toString().includes('strict-dynamic;'));
270270
});
271+
272+
it('should serve hashes via headers for dynamic pages, when the strategy is "auto"', async () => {
273+
fixture = await loadFixture({
274+
root: './fixtures/csp/',
275+
adapter: testAdapter(),
276+
experimental: {
277+
csp: true,
278+
},
279+
});
280+
await fixture.build();
281+
app = await fixture.loadTestAdapterApp();
282+
283+
const request = new Request('http://example.com/ssr');
284+
const response = await app.render(request);
285+
286+
const header = response.headers.get('content-security-policy');
287+
288+
assert.ok(header.includes('style-src https://styles.cdn.example.com'));
289+
290+
// correctness for resources
291+
assert.ok(header.includes("script-src 'self'"));
292+
// correctness for hashes
293+
assert.ok(header.includes("default-src 'self';"));
294+
295+
const html = await response.text();
296+
const $ = cheerio.load(html);
297+
298+
const meta = $('meta[http-equiv="Content-Security-Policy"]');
299+
assert.equal(meta.attr('content'), undefined, 'meta tag should not be present');
300+
});
271301
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
export const prerender = false;
3+
4+
Astro.insertStyleResource("https://styles.cdn.example.com");
5+
Astro.insertStyleHash('sha256-customHash');
6+
Astro.insertDirective("default-src 'self'");
7+
---
8+
9+
<html lang="en">
10+
<head>
11+
<meta charset="utf-8"/>
12+
<meta name="viewport" content="width=device-width"/>
13+
<title>Styles</title>
14+
</head>
15+
<body>
16+
<main>
17+
<h1>Styles</h1>
18+
</main>
19+
</body>
20+
</html>

packages/astro/test/test-adapter.js

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { viteID } from '../dist/core/util.js';
1+
import { viteID } from "../dist/core/util.js";
22

33
/**
44
* @typedef {import('../src/types/public/integrations.js').AstroAdapter} AstroAdapter
@@ -30,23 +30,25 @@ export default function ({
3030
env,
3131
} = {}) {
3232
return {
33-
name: 'my-ssr-adapter',
33+
name: "my-ssr-adapter",
3434
hooks: {
35-
'astro:config:setup': ({ updateConfig }) => {
35+
"astro:config:setup": ({ updateConfig }) => {
3636
updateConfig({
3737
vite: {
3838
plugins: [
3939
{
4040
resolveId(id) {
41-
if (id === '@my-ssr') {
41+
if (id === "@my-ssr") {
4242
return id;
43-
} else if (id === 'astro/app') {
44-
const viteId = viteID(new URL('../dist/core/app/index.js', import.meta.url));
43+
} else if (id === "astro/app") {
44+
const viteId = viteID(
45+
new URL("../dist/core/app/index.js", import.meta.url),
46+
);
4547
return viteId;
4648
}
4749
},
4850
load(id) {
49-
if (id === '@my-ssr') {
51+
if (id === "@my-ssr") {
5052
return {
5153
code: `
5254
import { App } from 'astro/app';
@@ -61,7 +63,7 @@ export default function ({
6163
return data[key];
6264
}))
6365
.catch(() => {});`
64-
: ''
66+
: ""
6567
}
6668
6769
class MyApp extends App {
@@ -82,7 +84,7 @@ export default function ({
8284
${
8385
provideAddress
8486
? `request[Symbol.for('astro.clientAddress')] = clientAddress ?? '0.0.0.0';`
85-
: ''
87+
: ""
8688
}
8789
return super.render(request, { routeData, locals, addCookieHeader, prerenderedErrorPageFetch });
8890
}
@@ -103,26 +105,27 @@ export default function ({
103105
},
104106
});
105107
},
106-
'astro:config:done': ({ setAdapter }) => {
108+
"astro:config:done": ({ setAdapter }) => {
107109
setAdapter({
108-
name: 'my-ssr-adapter',
109-
serverEntrypoint: '@my-ssr',
110-
exports: ['manifest', 'createApp'],
110+
name: "my-ssr-adapter",
111+
serverEntrypoint: "@my-ssr",
112+
exports: ["manifest", "createApp"],
111113
supportedAstroFeatures: {
112-
serverOutput: 'stable',
113-
envGetSecret: 'stable',
114-
staticOutput: 'stable',
115-
hybridOutput: 'stable',
116-
assets: 'stable',
117-
i18nDomains: 'stable',
114+
serverOutput: "stable",
115+
envGetSecret: "stable",
116+
staticOutput: "stable",
117+
hybridOutput: "stable",
118+
assets: "stable",
119+
i18nDomains: "stable",
120+
cspHeader: "stable",
118121
},
119122
adapterFeatures: {
120-
buildOutput: 'server',
123+
buildOutput: "server",
121124
},
122125
...extendAdapter,
123126
});
124127
},
125-
'astro:build:ssr': ({ entryPoints, middlewareEntryPoint, manifest }) => {
128+
"astro:build:ssr": ({ entryPoints, middlewareEntryPoint, manifest }) => {
126129
if (setEntryPoints) {
127130
setEntryPoints(entryPoints);
128131
}
@@ -133,7 +136,7 @@ export default function ({
133136
setManifest(manifest);
134137
}
135138
},
136-
'astro:build:done': ({ routes }) => {
139+
"astro:build:done": ({ routes }) => {
137140
if (setRoutes) {
138141
setRoutes(routes);
139142
}

0 commit comments

Comments
 (0)