From 82c67d219f99ebab3a70bd74d1be0152f7c0bba0 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 19 May 2025 12:58:28 +0100 Subject: [PATCH 1/3] feat(csp): runtime api --- packages/astro/src/actions/runtime/utils.ts | 4 + packages/astro/src/core/app/types.ts | 2 +- .../astro/src/core/config/schemas/base.ts | 15 +--- packages/astro/src/core/csp/common.ts | 2 +- packages/astro/src/core/csp/config.ts | 20 +++-- packages/astro/src/core/middleware/index.ts | 4 + packages/astro/src/core/render-context.ts | 80 ++++++++++++++++--- .../astro/src/runtime/server/render/csp.ts | 10 +-- packages/astro/src/types/public/config.ts | 2 +- packages/astro/src/types/public/context.ts | 55 ++++++++++++- packages/astro/test/csp.test.js | 62 ++++++++++++-- .../test/fixtures/csp/src/pages/scripts.astro | 18 +++++ .../test/fixtures/csp/src/pages/styles.astro | 18 +++++ 13 files changed, 244 insertions(+), 48 deletions(-) create mode 100644 packages/astro/test/fixtures/csp/src/pages/scripts.astro create mode 100644 packages/astro/test/fixtures/csp/src/pages/styles.astro diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index a3b49464f7da..64132d3b87af 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -41,6 +41,10 @@ export type ActionAPIContext = Pick< | 'originPathname' | 'session' | 'insertDirective' + | 'insertScriptResource' + | 'insertStyleResource' + | 'insertScriptHash' + | 'insertStyleHash' > & { // TODO: remove in Astro 6.0 /** diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 343267d175a0..4212eefc7938 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -114,7 +114,7 @@ export type SSRManifestCSP = { scriptResources: string[]; styleHashes: string[]; styleResources: string[]; - directives: CspDirective; + directives: CspDirective[]; }; /** Public type exposed through the `astro:build:ssr` integration hook */ diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 3e87eabe47cf..47a1fc0317d9 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -11,7 +11,7 @@ import { z } from 'zod'; import { localFontFamilySchema, remoteFontFamilySchema } from '../../../assets/fonts/config.js'; import { EnvSchema } from '../../../env/schema.js'; import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js'; -import { ALLOWED_DIRECTIVES, cspAlgorithmSchema, CspHashSchema } from '../../csp/config.js'; +import { allowedDirectivesSchema, cspAlgorithmSchema, cspHashSchema } from '../../csp/config.js'; // The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version, // Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references @@ -480,24 +480,17 @@ export const AstroConfigSchema = z.object({ z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), z.object({ algorithm: cspAlgorithmSchema, - directives: z - .array( - z.object({ - type: z.enum(ALLOWED_DIRECTIVES), - value: z.string(), - }), - ) - .optional(), + directives: z.array(allowedDirectivesSchema).optional(), styleDirective: z .object({ resources: z.array(z.string()).optional(), - hashes: z.array(CspHashSchema).optional(), + hashes: z.array(cspHashSchema).optional(), }) .optional(), scriptDirective: z .object({ resources: z.array(z.string()).optional(), - hashes: z.array(CspHashSchema).optional(), + hashes: z.array(cspHashSchema).optional(), }) .optional(), }), diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index d216a9509982..8ddaae0ec874 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -51,7 +51,7 @@ export function getStyleResources(csp: EnabledCsp): string[] { return csp.styleDirective?.resources ?? []; } -export function getDirectives(csp: EnabledCsp): CspDirective { +export function getDirectives(csp: EnabledCsp): CspDirective[] { if (csp === true) { return []; } diff --git a/packages/astro/src/core/csp/config.ts b/packages/astro/src/core/csp/config.ts index 2df9ecb0d83e..d3a878b5490b 100644 --- a/packages/astro/src/core/csp/config.ts +++ b/packages/astro/src/core/csp/config.ts @@ -30,7 +30,7 @@ export const cspAlgorithmSchema = z .optional() .default('SHA-256'); -export const CspHashSchema = z.custom<`${CspAlgorithmValue}${string}`>((value) => { +export const cspHashSchema = z.custom<`${CspAlgorithmValue}${string}`>((value) => { if (typeof value !== 'string') { return false; } @@ -39,7 +39,7 @@ export const CspHashSchema = z.custom<`${CspAlgorithmValue}${string}`>((value) = }); }); -export type CspHash = z.infer; +export type CspHash = z.infer; export const ALLOWED_DIRECTIVES = [ 'base-uri', @@ -64,9 +64,15 @@ export const ALLOWED_DIRECTIVES = [ 'worker-src', ] as const; -type AllowedDirectives = (typeof ALLOWED_DIRECTIVES)[number]; +export type CspDirective = `${AllowedDirectives} ${string}`; -export type CspDirective = { - type: AllowedDirectives; - value: string; -}[]; +export const allowedDirectivesSchema = z.custom((value) => { + if (typeof value !== 'string') { + return false; + } + return ALLOWED_DIRECTIVES.some((allowedValue) => { + return value.startsWith(allowedValue); + }); +}); + +type AllowedDirectives = (typeof ALLOWED_DIRECTIVES)[number]; diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index b4b1410ea96d..ae5b749edac6 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -124,6 +124,10 @@ function createContext({ throw new AstroError(AstroErrorData.LocalsReassigned); }, insertDirective() {}, + insertScriptResource() {}, + insertStyleResource() {}, + insertScriptHash() {}, + insertStyleHash() {}, }; return Object.assign(context, { getActionResult: createGetActionResult(context.locals), diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 799c63ed20e6..91568e1f7c0b 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -37,7 +37,6 @@ import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rew import { AstroSession } from './session.js'; export const apiContextRoutesSymbol = Symbol.for('context.routes'); - /** * Each request is rendered using a `RenderContext`. * It contains data unique to each request. It is responsible for executing middleware, calling endpoints, and rendering the page by gathering necessary data from a `Pipeline`. @@ -73,6 +72,8 @@ export class RenderContext { */ counter = 0; + result: SSRResult | undefined = undefined; + static async create({ locals = {}, middleware, @@ -223,10 +224,10 @@ export class RenderContext { case 'redirect': return renderRedirect(this); case 'page': { - const result = await this.createResult(componentInstance!, actionApiContext); + this.result = await this.createResult(componentInstance!, actionApiContext); try { response = await renderPage( - result, + this.result, componentInstance?.default as any, props, slots, @@ -236,7 +237,7 @@ export class RenderContext { } catch (e) { // If there is an error in the page's frontmatter or instantiation of the RenderTemplate fails midway, // we signal to the rest of the internals that we can ignore the results of existing renders and avoid kicking off more of them. - result.cancelled = true; + this.result.cancelled = true; throw e; } @@ -394,11 +395,37 @@ export class RenderContext { } return renderContext.session; }, - insertDirective(_payload) { + insertDirective(payload) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.directives.push(payload); + }, + + insertScriptResource(resource) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.scriptResources.push(resource); + }, + insertStyleResource(resource) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + + renderContext.result?.styleResources.push(resource); + }, + insertStyleHash(hash) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.styleHashes.push(hash); + }, + insertScriptHash(hash) { if (!!pipeline.manifest.csp === false) { throw new AstroError(CspNotEnabled); } - // TODO: add the directive + renderContext.result?.scriptHashes.push(hash); }, }; } @@ -470,12 +497,13 @@ export class RenderContext { propagators: new Set(), }, shouldInjectCspMetaTags: !!manifest.csp, - scriptHashes: manifest.csp?.scriptHashes ?? [], - scriptResources: manifest.csp?.scriptResources ?? [], - styleHashes: manifest.csp?.styleHashes ?? [], - styleResources: manifest.csp?.styleResources ?? [], cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256', - directives: manifest.csp?.directives ?? [], + // The following arrays must be cloned, otherwise they become mutable across routes. + scriptHashes: !!manifest.csp?.scriptHashes ? [...manifest.csp.scriptHashes] : [], + scriptResources: !!manifest.csp?.scriptResources ? [...manifest.csp.scriptResources] : [], + styleHashes: !!manifest.csp?.styleHashes ? [...manifest.csp.styleHashes] : [], + styleResources: !!manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [], + directives: !!manifest.csp?.directives ? [...manifest.csp.directives] : [], }; return result; @@ -615,11 +643,37 @@ export class RenderContext { get originPathname() { return getOriginPathname(renderContext.request); }, - insertDirective(_payload) { + insertDirective(payload) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.directives.push(payload); + }, + + insertScriptResource(resource) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.scriptResources.push(resource); + }, + insertStyleResource(resource) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + + renderContext.result?.styleResources.push(resource); + }, + insertStyleHash(hash) { + if (!!pipeline.manifest.csp === false) { + throw new AstroError(CspNotEnabled); + } + renderContext.result?.styleHashes.push(hash); + }, + insertScriptHash(hash) { if (!!pipeline.manifest.csp === false) { throw new AstroError(CspNotEnabled); } - // TODO: add the directive + renderContext.result?.scriptHashes.push(hash); }, }; } diff --git a/packages/astro/src/runtime/server/render/csp.ts b/packages/astro/src/runtime/server/render/csp.ts index 6a62def579c2..a1a1556c42c5 100644 --- a/packages/astro/src/runtime/server/render/csp.ts +++ b/packages/astro/src/runtime/server/render/csp.ts @@ -19,11 +19,11 @@ export function renderCspContent(result: SSRResult): string { for (const scriptHash of result._metadata.extraScriptHashes) { finalScriptHashes.add(`'${scriptHash}'`); } - const directives = result.directives - .map(({ type, value }) => { - return `${type} ${value}`; - }) - .join(';'); + + let directives = ''; + if (result.directives.length > 0) { + directives = result.directives.join(';') + ';'; + } let scriptResources = "'self'"; if (result.scriptResources.length > 0) { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 02a243f16d1d..f7f03580294a 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2357,7 +2357,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * ``` * */ - directives?: CspDirective; + directives?: CspDirective[]; }; /** diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index a3c96d48cdd0..7532b17f1c54 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -357,10 +357,59 @@ export interface AstroSharedContext< isPrerendered: boolean; /** - * When CSP is enabled, it allows - * @param payload + * It adds a specific CSP directive to the route being rendered. + * + * ## Example + * + * ```js + * ctx.insertDirective("default-src 'self' 'unsafe-inline' https://example.com") + * ``` + */ + insertDirective: (directive: CspDirective) => void; + + /** + * It set the resource for the directive `style-src` in the route being rendered. It overrides Astro's default. + * + * ## Example + * + * ```js + * ctx.insertStyleResource("https://styles.cdn.example.com/") + * ``` + */ + insertStyleResource: (payload: string) => void; + + /** + * Insert a single style hash to the route being rendered. + * + * ## Example + * + * ```js + * ctx.insertStyleHash("sha256-1234567890abcdef1234567890") + * ``` + */ + insertStyleHash: (hash: string) => void; + + /** + * It set the resource for the directive `script-src` in the route being rendered. + * + * ## Example + * + * ```js + * ctx.insertScriptResource("https://scripts.cdn.example.com/") + * ``` + */ + insertScriptResource: (resource: string) => void; + + /** + * Insert a single script hash to the route being rendered. + * + * ## Example + * + * ```js + * ctx.insertScriptHash("sha256-1234567890abcdef1234567890") + * ``` */ - insertDirective: (payload: CspDirective) => void; + insertScriptHash: (hash: string) => void; } /** diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 55980f8f497b..893364e20f0a 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -159,12 +159,7 @@ describe('CSP', () => { }), experimental: { csp: { - directives: [ - { - type: 'img-src', - value: "'self' 'https://example.com'", - }, - ], + directives: ["img-src 'self' 'https://example.com'"], }, }, }); @@ -221,4 +216,59 @@ describe('CSP', () => { .includes("style-src 'https://cdn.example.com' 'https://styles.cdn.example.com'"), ); }); + + it('allows injecting custom script resources and hashes based on pages', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/scripts/index.html'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + // correctness for resources + assert.ok( + meta.attr('content').toString().includes("script-src 'https://scripts.cdn.example.com'"), + ); + assert.ok(meta.attr('content').toString().includes("style-src 'self'")); + // correctness for hashes + assert.ok(meta.attr('content').toString().includes("default-src 'self';")); + }); + + it('allows injecting custom styles resources and hashes based on pages', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/styles/index.html'); + const response = await app.render(request); + const html = await response.text(); + console.log(html); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + // correctness for resources + assert.ok( + meta.attr('content').toString().includes("style-src 'https://styles.cdn.example.com'"), + ); + assert.ok(meta.attr('content').toString().includes("script-src 'self'")); + // correctness for hashes + assert.ok(meta.attr('content').toString().includes("default-src 'self';")); + }); }); diff --git a/packages/astro/test/fixtures/csp/src/pages/scripts.astro b/packages/astro/test/fixtures/csp/src/pages/scripts.astro new file mode 100644 index 000000000000..3e90aca551e5 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/scripts.astro @@ -0,0 +1,18 @@ +--- +Astro.insertScriptResource("https://scripts.cdn.example.com"); +Astro.insertScriptHash('sha256-customHash'); +Astro.insertDirective("default-src 'self'"); +--- + + + + + + Scripts + + +
+

Scripts

+
+ + diff --git a/packages/astro/test/fixtures/csp/src/pages/styles.astro b/packages/astro/test/fixtures/csp/src/pages/styles.astro new file mode 100644 index 000000000000..efb830d510bd --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/styles.astro @@ -0,0 +1,18 @@ +--- +Astro.insertStyleResource("https://styles.cdn.example.com"); +Astro.insertStyleHash('sha256-customHash'); +Astro.insertDirective("default-src 'self'"); +--- + + + + + + Styles + + +
+

Styles

+
+ + From fa51fe0a6809cc5ea30f4edcf8795ee52f10bbfd Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 19 May 2025 16:09:50 +0100 Subject: [PATCH 2/3] linting --- packages/astro/src/core/csp/config.ts | 2 +- packages/astro/test/csp.test.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/astro/src/core/csp/config.ts b/packages/astro/src/core/csp/config.ts index d3a878b5490b..68b678afba0b 100644 --- a/packages/astro/src/core/csp/config.ts +++ b/packages/astro/src/core/csp/config.ts @@ -41,7 +41,7 @@ export const cspHashSchema = z.custom<`${CspAlgorithmValue}${string}`>((value) = export type CspHash = z.infer; -export const ALLOWED_DIRECTIVES = [ +const ALLOWED_DIRECTIVES = [ 'base-uri', 'child-src', 'connect-src', diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 893364e20f0a..83991e6a7637 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -259,7 +259,6 @@ describe('CSP', () => { const request = new Request('http://example.com/styles/index.html'); const response = await app.render(request); const html = await response.text(); - console.log(html); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); From 70f7718921a6543348accc5d469838a24168e385 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 21 May 2025 10:10:14 +0100 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Matt Kane --- packages/astro/src/core/render-context.ts | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 91568e1f7c0b..6663c2c153b4 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -396,27 +396,27 @@ export class RenderContext { return renderContext.session; }, insertDirective(payload) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.directives.push(payload); }, insertScriptResource(resource) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.scriptResources.push(resource); }, insertStyleResource(resource) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.styleResources.push(resource); }, insertStyleHash(hash) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.styleHashes.push(hash); @@ -499,11 +499,11 @@ export class RenderContext { shouldInjectCspMetaTags: !!manifest.csp, cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256', // The following arrays must be cloned, otherwise they become mutable across routes. - scriptHashes: !!manifest.csp?.scriptHashes ? [...manifest.csp.scriptHashes] : [], - scriptResources: !!manifest.csp?.scriptResources ? [...manifest.csp.scriptResources] : [], - styleHashes: !!manifest.csp?.styleHashes ? [...manifest.csp.styleHashes] : [], - styleResources: !!manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [], - directives: !!manifest.csp?.directives ? [...manifest.csp.directives] : [], + scriptHashes: manifest.csp?.scriptHashes ? [...manifest.csp.scriptHashes] : [], + scriptResources: manifest.csp?.scriptResources ? [...manifest.csp.scriptResources] : [], + styleHashes: manifest.csp?.styleHashes ? [...manifest.csp.styleHashes] : [], + styleResources: manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [], + directives: manifest.csp?.directives ? [...manifest.csp.directives] : [], }; return result; @@ -644,27 +644,27 @@ export class RenderContext { return getOriginPathname(renderContext.request); }, insertDirective(payload) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.directives.push(payload); }, insertScriptResource(resource) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.scriptResources.push(resource); }, insertStyleResource(resource) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.styleResources.push(resource); }, insertStyleHash(hash) { - if (!!pipeline.manifest.csp === false) { + if (!pipeline.manifest.csp) { throw new AstroError(CspNotEnabled); } renderContext.result?.styleHashes.push(hash);