From 39fb70a31fd0d78b871e152fb8a21423de46f8b0 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 14 May 2025 12:08:24 +0100 Subject: [PATCH 01/10] feat(csp): custom algorithm --- packages/astro/src/container/index.ts | 8 +-- packages/astro/src/core/app/types.ts | 24 ++++--- packages/astro/src/core/base-pipeline.ts | 5 -- packages/astro/src/core/build/generate.ts | 35 +++++++---- .../src/core/build/plugins/plugin-manifest.ts | 26 +++++--- .../astro/src/core/config/schemas/base.ts | 7 ++- packages/astro/src/core/csp/common.ts | 41 ++++++++---- packages/astro/src/core/csp/middleware.ts | 11 ---- packages/astro/src/core/encryption.ts | 34 +++++++++- packages/astro/src/core/render-context.ts | 7 ++- .../astro/src/runtime/server/render/csp.ts | 8 +-- .../runtime/server/render/server-islands.ts | 8 ++- .../astro/src/runtime/server/transition.ts | 4 +- packages/astro/src/types/public/config.ts | 28 ++++++++- packages/astro/src/types/public/internal.ts | 7 ++- .../src/vite-plugin-astro-server/plugin.ts | 20 ++++-- packages/astro/test/csp.test.js | 63 ++++++++++++++++++- 17 files changed, 245 insertions(+), 91 deletions(-) delete mode 100644 packages/astro/src/core/csp/middleware.ts diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index dddd6b09e09e..df400ae85ec6 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -161,9 +161,7 @@ function createManifest( checkOrigin: false, middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), - clientScriptHashes: manifest?.clientScriptHashes ?? [], - clientStyleHashes: manifest?.clientStyleHashes ?? [], - shouldInjectCspMetaTags: manifest?.shouldInjectCspMetaTags ?? false, + csp: manifest?.csp, }; } @@ -249,9 +247,7 @@ type AstroContainerManifest = Pick< | 'publicDir' | 'outDir' | 'cacheDir' - | 'clientScriptHashes' - | 'clientStyleHashes' - | 'shouldInjectCspMetaTags' + | 'csp' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 18f926eb37f0..66d7509daa90 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -3,7 +3,12 @@ import type { ActionAccept, ActionClient } from '../../actions/runtime/virtual/s import type { RoutingStrategies } from '../../i18n/utils.js'; import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js'; import type { AstroMiddlewareInstance } from '../../types/public/common.js'; -import type { AstroConfig, Locales, ResolvedSessionConfig } from '../../types/public/config.js'; +import type { + AstroConfig, + CspAlgorithm, + Locales, + ResolvedSessionConfig, +} from '../../types/public/config.js'; import type { RouteData, SSRComponentMetadata, @@ -86,12 +91,7 @@ export type SSRManifest = { publicDir: string | URL; buildClientDir: string | URL; buildServerDir: string | URL; - clientScriptHashes: string[]; - clientStyleHashes: string[]; - /** - * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag - */ - shouldInjectCspMetaTags: boolean; + csp: SSRManifestCSP | undefined; }; export type SSRActions = { @@ -107,6 +107,16 @@ export type SSRManifestI18n = { domainLookupTable: Record; }; +export type SSRManifestCSP = { + algorithm: CspAlgorithm; + clientScriptHashes: string[]; + clientStyleHashes: string[]; + /** + * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag + */ + shouldInjectCspMetaTags: boolean; +}; + /** Public type exposed through the `astro:build:ssr` integration hook */ export type SerializedSSRManifest = Omit< SSRManifest, diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index d239a4a2fae2..3ac79219588e 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -13,7 +13,6 @@ import type { } from '../types/public/internal.js'; import { createOriginCheckMiddleware } from './app/middlewares.js'; import type { SSRActions } from './app/types.js'; -import { createCSPMiddleware } from './csp/middleware.js'; import { ActionNotFoundError } from './errors/errors-data.js'; import { AstroError } from './errors/index.js'; import type { Logger } from './logger/core.js'; @@ -118,10 +117,6 @@ export abstract class Pipeline { // this middleware must be placed at the beginning because it needs to block incoming requests internalMiddlewares.unshift(createOriginCheckMiddleware()); } - if (this.manifest.shouldInjectCspMetaTags) { - // this middleware must be placed at the end because it needs to inject the CSP headers - internalMiddlewares.push(createCSPMiddleware()); - } this.resolvedMiddleware = sequence(...internalMiddlewares); return this.resolvedMiddleware; } else { diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 5e2c5485d287..a00a99dd9e08 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -27,8 +27,13 @@ import type { SSRError, SSRLoadedRenderer, } from '../../types/public/internal.js'; -import type { SSRActions, SSRManifest, SSRManifestI18n } from '../app/types.js'; -import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../csp/common.js'; +import type { SSRActions, SSRManifestCSP, SSRManifest, SSRManifestI18n } from '../app/types.js'; +import { + getAlgorithm, + shouldTrackCspHashes, + trackScriptHashes, + trackStyleHashes, +} from '../csp/common.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js'; @@ -612,14 +617,7 @@ async function createBuildManifest( key: Promise, ): Promise { let i18nManifest: SSRManifestI18n | undefined = undefined; - - let clientStyleHashes: string[] = []; - let clientScriptHashes: string[] = []; - - if (shouldTrackCspHashes(settings.config)) { - clientScriptHashes = await trackScriptHashes(internals, settings); - clientStyleHashes = await trackStyleHashes(internals, settings); - } + let csp: SSRManifestCSP | undefined = undefined; if (settings.config.i18n) { i18nManifest = { @@ -631,6 +629,19 @@ async function createBuildManifest( domainLookupTable: {}, }; } + + if (shouldTrackCspHashes(settings.config)) { + const algorithm = getAlgorithm(settings.config); + const clientScriptHashes = await trackScriptHashes(internals, settings, algorithm); + const clientStyleHashes = await trackStyleHashes(internals, settings, algorithm); + + csp = { + clientStyleHashes, + clientScriptHashes, + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + algorithm: getAlgorithm(settings.config), + }; + } return { hrefRoot: settings.config.root.toString(), srcDir: settings.config.srcDir, @@ -664,8 +675,6 @@ async function createBuildManifest( checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, key, - clientStyleHashes, - clientScriptHashes, - shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + csp, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 75fb1b466c0a..f8d5f40f4d8e 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -12,7 +12,12 @@ import type { SerializedRouteInfo, SerializedSSRManifest, } from '../../app/types.js'; -import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../../csp/common.js'; +import { + getAlgorithm, + shouldTrackCspHashes, + trackScriptHashes, + trackStyleHashes, +} from '../../csp/common.js'; import { encodeKey } from '../../encryption.js'; import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js'; import { DEFAULT_COMPONENTS } from '../../routing/default.js'; @@ -276,12 +281,19 @@ async function buildManifest( }; } - let clientScriptHashes: string[] = []; - let clientStyleHashes: string[] = []; + let csp = undefined; if (shouldTrackCspHashes(settings.config)) { - clientScriptHashes = await trackScriptHashes(internals, opts.settings); - clientStyleHashes = await trackStyleHashes(internals, opts.settings); + const algorithm = getAlgorithm(settings.config); + const clientScriptHashes = await trackScriptHashes(internals, opts.settings, algorithm); + const clientStyleHashes = await trackStyleHashes(internals, opts.settings, algorithm); + + csp = { + shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), + clientStyleHashes, + clientScriptHashes, + algorithm, + }; } return { @@ -313,8 +325,6 @@ async function buildManifest( serverIslandNameMap: Array.from(settings.serverIslandNameMap), key: encodedKey, sessionConfig: settings.config.session, - shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), - clientStyleHashes, - clientScriptHashes, + csp, }; } diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 5e7e30b07c63..7f11d495b22e 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -474,7 +474,12 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.preserveScriptOrder), fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(), - csp: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), + csp: z.union([ + z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), + z.object({ + algorithm: z.enum(['SHA-512', 'SHA-384', 'SHA-256']).optional().default('SHA-256'), + }), + ]).optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index d1ed301f7432..0cd82e013b4a 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -4,23 +4,39 @@ import { ISLAND_STYLES } from '../../runtime/server/astro-island-styles.js'; import astroIslandPrebuiltDev from '../../runtime/server/astro-island.prebuilt-dev.js'; import astroIslandPrebuilt from '../../runtime/server/astro-island.prebuilt.js'; import type { AstroSettings } from '../../types/astro.js'; -import type { AstroConfig } from '../../types/public/index.js'; +import type { AstroConfig, CspAlgorithm } from '../../types/public/index.js'; import type { BuildInternals } from '../build/internal.js'; -import { generateDigest } from '../encryption.js'; +import { generateCspDigest } from '../encryption.js'; export function shouldTrackCspHashes(config: AstroConfig): boolean { - return config.experimental?.csp === true; + return config.experimental?.csp === true || typeof config.experimental?.csp === 'object'; +} + +/** + * Use this function when after you checked that CSP is enabled, or it throws an error. + * @param config + */ +export function getAlgorithm(config: AstroConfig): CspAlgorithm { + if (!config.experimental?.csp) { + throw new Error('CSP is not enabled'); + } + if (config.experimental?.csp === true) { + return 'SHA-256'; + } else { + return config.experimental.csp.algorithm; + } } export async function trackStyleHashes( internals: BuildInternals, settings: AstroSettings, + algorithm: CspAlgorithm, ): Promise { const clientStyleHashes: string[] = []; for (const [_, page] of internals.pagesByViteID.entries()) { for (const style of page.styles) { if (style.sheet.type === 'inline') { - clientStyleHashes.push(await generateDigest(style.sheet.content)); + clientStyleHashes.push(await generateCspDigest(style.sheet.content, algorithm)); } } } @@ -31,12 +47,12 @@ export async function trackStyleHashes( 'utf-8', ); if (clientAsset.endsWith('.css') || clientAsset.endsWith('.css')) { - clientStyleHashes.push(await generateDigest(contents)); + clientStyleHashes.push(await generateCspDigest(contents, algorithm)); } } if (settings.renderers.length > 0) { - clientStyleHashes.push(await generateDigest(ISLAND_STYLES)); + clientStyleHashes.push(await generateCspDigest(ISLAND_STYLES, algorithm)); } return clientStyleHashes; @@ -45,15 +61,16 @@ export async function trackStyleHashes( export async function trackScriptHashes( internals: BuildInternals, settings: AstroSettings, + algorithm: CspAlgorithm, ): Promise { const clientScriptHashes: string[] = []; for (const script of internals.inlinedScripts.values()) { - clientScriptHashes.push(await generateDigest(script)); + clientScriptHashes.push(await generateCspDigest(script, algorithm)); } for (const directiveContent of Array.from(settings.clientDirectives.values())) { - clientScriptHashes.push(await generateDigest(directiveContent)); + clientScriptHashes.push(await generateCspDigest(directiveContent, algorithm)); } for (const clientAsset in internals.clientChunksAndAssets) { @@ -62,20 +79,20 @@ export async function trackScriptHashes( 'utf-8', ); if (clientAsset.endsWith('.js') || clientAsset.endsWith('.mjs')) { - clientScriptHashes.push(await generateDigest(contents)); + clientScriptHashes.push(await generateCspDigest(contents, algorithm)); } } for (const script of settings.scripts) { const { content, stage } = script; if (stage === 'head-inline' || stage === 'before-hydration') { - clientScriptHashes.push(await generateDigest(content)); + clientScriptHashes.push(await generateCspDigest(content, algorithm)); } } if (settings.renderers.length > 0) { - clientScriptHashes.push(await generateDigest(astroIslandPrebuilt)); - clientScriptHashes.push(await generateDigest(astroIslandPrebuiltDev)); + clientScriptHashes.push(await generateCspDigest(astroIslandPrebuilt, algorithm)); + clientScriptHashes.push(await generateCspDigest(astroIslandPrebuiltDev, algorithm)); } return clientScriptHashes; diff --git a/packages/astro/src/core/csp/middleware.ts b/packages/astro/src/core/csp/middleware.ts deleted file mode 100644 index f05a8443b75f..000000000000 --- a/packages/astro/src/core/csp/middleware.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MiddlewareHandler } from '../../types/public/index.js'; - -export function createCSPMiddleware(): MiddlewareHandler { - return async (_, next) => { - const response = await next(); - - // Do something with the response - - return response; - }; -} diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index e3668ab5d865..f8dc3913200a 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -1,4 +1,5 @@ import { decodeBase64, decodeHex, encodeBase64, encodeHexUpperCase } from '@oslojs/encoding'; +import type { CspAlgorithm } from '../types/public/index.js'; // Chose this algorithm for no particular reason, can change. // This algo does check against text manipulation though. See @@ -113,9 +114,36 @@ export async function decryptString(key: CryptoKey, encoded: string) { /** * Generates an SHA-256 digest of the given string. * @param {string} data The string to hash. + * @param {'sha256' | 'sha512' | 'sha384'} algorithm The algorithm to use. */ -export async function generateDigest(data: string): Promise { - const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data)); +export async function generateCspDigest(data: string, algorithm: CspAlgorithm): Promise { + let hashBuffer; + switch (algorithm) { + case 'SHA-256': { + hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data)); + break; + } + case 'SHA-512': { + hashBuffer = await crypto.subtle.digest('SHA-512', encoder.encode(data)); + break; + } + case 'SHA-384': { + hashBuffer = await crypto.subtle.digest('SHA-384', encoder.encode(data)); + break; + } + } + + const hash = encodeBase64(new Uint8Array(hashBuffer)); - return encodeBase64(new Uint8Array(hashBuffer)); + switch (algorithm) { + case 'SHA-256': { + return `sha256-${hash}`; + } + case 'SHA-512': { + return `sha512-${hash}`; + } + case 'SHA-384': { + return `sha384-${hash}`; + } + } } diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 4bed34435f9e..c3bce14ca539 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -463,9 +463,10 @@ export class RenderContext { extraScriptHashes: [], propagators: new Set(), }, - shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags, - clientScriptHashes: manifest.clientScriptHashes, - clientStyleHashes: manifest.clientStyleHashes, + shouldInjectCspMetaTags: manifest.csp?.shouldInjectCspMetaTags ?? false, + clientScriptHashes: manifest.csp?.clientScriptHashes ?? [], + clientStyleHashes: manifest.csp?.clientStyleHashes ?? [], + cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256', }; return result; diff --git a/packages/astro/src/runtime/server/render/csp.ts b/packages/astro/src/runtime/server/render/csp.ts index d7e1ad5dc73d..3f861e8ae925 100644 --- a/packages/astro/src/runtime/server/render/csp.ts +++ b/packages/astro/src/runtime/server/render/csp.ts @@ -5,19 +5,19 @@ export function renderCspContent(result: SSRResult): string { const finalStyleHashes = new Set(); for (const scriptHash of result.clientScriptHashes) { - finalScriptHashes.add(`'sha256-${scriptHash}'`); + finalScriptHashes.add(`'${scriptHash}'`); } for (const styleHash of result.clientStyleHashes) { - finalStyleHashes.add(`'sha256-${styleHash}'`); + finalStyleHashes.add(`'${styleHash}'`); } for (const styleHash of result._metadata.extraStyleHashes) { - finalStyleHashes.add(`'sha256-${styleHash}'`); + finalStyleHashes.add(`'${styleHash}'`); } for (const scriptHash of result._metadata.extraScriptHashes) { - finalScriptHashes.add(`'sha256-${scriptHash}'`); + finalScriptHashes.add(`'${scriptHash}'`); } const scriptSrc = `style-src 'self' ${Array.from(finalStyleHashes).join(' ')};`; diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts index 338da76bf890..8e830718e7af 100644 --- a/packages/astro/src/runtime/server/render/server-islands.ts +++ b/packages/astro/src/runtime/server/render/server-islands.ts @@ -1,4 +1,4 @@ -import { encryptString, generateDigest } from '../../../core/encryption.js'; +import { encryptString, generateCspDigest } from '../../../core/encryption.js'; import type { SSRResult } from '../../../types/public/internal.js'; import { markHTMLString } from '../escape.js'; import { renderChild } from './any.js'; @@ -137,8 +137,10 @@ let response = await fetch('${serverIslandUrl}', { const content = `${method}replaceServerIsland('${hostId}', response);`; if (this.result.shouldInjectCspMetaTags) { - this.result._metadata.extraScriptHashes.push(await generateDigest(SERVER_ISLAND_REPLACER)); - const contentDigest = await generateDigest(content); + this.result._metadata.extraScriptHashes.push( + await generateCspDigest(SERVER_ISLAND_REPLACER, this.result.cspAlgorithm), + ); + const contentDigest = await generateCspDigest(content, this.result.cspAlgorithm); this.result._metadata.extraScriptHashes.push(contentDigest); } this.islandContent = content; diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts index 92940c745a31..bae71248975b 100644 --- a/packages/astro/src/runtime/server/transition.ts +++ b/packages/astro/src/runtime/server/transition.ts @@ -1,5 +1,5 @@ import cssesc from 'cssesc'; -import { generateDigest } from '../../core/encryption.js'; +import { generateCspDigest } from '../../core/encryption.js'; import { fade, slide } from '../../transitions/index.js'; import type { SSRResult } from '../../types/public/internal.js'; import type { @@ -113,7 +113,7 @@ export async function renderTransition( const css = sheet.toString(); if (result.shouldInjectCspMetaTags) { - result._metadata.extraStyleHashes.push(await generateDigest(css)); + result._metadata.extraStyleHashes.push(await generateCspDigest(css, result.cspAlgorithm)); } result._metadata.extraHead.push(markHTMLString(``)); return scope; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 97816d8c0111..325749f4696b 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -172,6 +172,8 @@ export interface ViteUserConfig extends OriginalViteUserConfig { ssr?: ViteSSROptions; } +export type CspAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512'; + // NOTE(fks): We choose to keep our hand-generated AstroUserConfig interface so that // we can add JSDoc-style documentation and link to the definition file in our repo. // However, Zod comes with the ability to auto-generate AstroConfig from the schema @@ -2227,10 +2229,32 @@ export interface ViteUserConfig extends OriginalViteUserConfig { headingIdCompat?: boolean; /** + * @name experimental.csp + * @type {boolean} + * @default `false` + * @version 5.5.x + * @description + * + * Enables built-in support for Content Security Policy (CSP). * */ - // TODO: add docs once we are reaching the end - csp?: boolean; + csp?: + | boolean + | { + /** + * @name experimental.csp.algorithm + * @type {string} + * @default `'sha256'` + * @version 5.5.x + * @description + * + * The hashing algorithm to use for the CSP. + * + * The default value is `'sha256'`. + * + */ + algorithm?: CspAlgorithm; + }; /** * @name experimental.preserveScriptOrder diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index 1f72b5b02625..e81e5ebfcb3a 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -1,7 +1,7 @@ // TODO: Should the types here really be public? import type { ErrorPayload as ViteErrorPayload } from 'vite'; -import type { SSRManifest } from '../../core/app/types.js'; +import type { SSRManifestCSP } from '../../core/app/types.js'; import type { AstroCookies } from '../../core/cookies/cookies.js'; import type { AstroComponentInstance, ServerIslandComponent } from '../../runtime/server/index.js'; import type { Params } from './common.js'; @@ -251,8 +251,9 @@ export interface SSRResult { * Whether Astro should inject the CSP tag into the head of the component. */ shouldInjectCspMetaTags: boolean; - clientScriptHashes: SSRManifest['clientScriptHashes']; - clientStyleHashes: SSRManifest['clientStyleHashes']; + cspAlgorithm: SSRManifestCSP['algorithm']; + clientScriptHashes: SSRManifestCSP['clientScriptHashes']; + clientStyleHashes: SSRManifestCSP['clientStyleHashes']; } /** diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 567afee158f2..c66823c5e0a1 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -4,8 +4,8 @@ import { IncomingMessage } from 'node:http'; import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; -import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js'; -import { shouldTrackCspHashes } from '../core/csp/common.js'; +import type { SSRManifestCSP, SSRManifest, SSRManifestI18n } from '../core/app/types.js'; +import { getAlgorithm, shouldTrackCspHashes } from '../core/csp/common.js'; import { warnMissingAdapter } from '../core/dev/adapter-validation.js'; import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../core/encryption.js'; import { getViteErrorPayload } from '../core/errors/dev/index.js'; @@ -162,7 +162,8 @@ export default function createVitePluginAstroServer({ * @param settings */ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest { - let i18nManifest: SSRManifestI18n | undefined = undefined; + let i18nManifest: SSRManifestI18n | undefined; + let csp: SSRManifestCSP | undefined; if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, @@ -174,6 +175,15 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest }; } + if (shouldTrackCspHashes(settings.config)) { + csp = { + clientScriptHashes: [], + clientStyleHashes: [], + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + algorithm: getAlgorithm(settings.config), + }; + } + return { hrefRoot: settings.config.root.toString(), srcDir: settings.config.srcDir, @@ -207,8 +217,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest }; }, sessionConfig: settings.config.session, - clientScriptHashes: [], - clientStyleHashes: [], - shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + csp, }; } diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 2d2fcc2bba2c..3516b0f1f2ee 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -32,10 +32,10 @@ describe('CSP', () => { const $ = cheerio.load(await response.text()); const meta = $('meta[http-equiv="Content-Security-Policy"]'); - for (const hash of manifest.clientStyleHashes) { + for (const hash of manifest.csp.clientStyleHashes) { assert.match( meta.attr('content'), - new RegExp(`'sha256-${hash}'`), + new RegExp(`'${hash}'`), `Should have a CSP meta tag for ${hash}`, ); } @@ -43,4 +43,63 @@ describe('CSP', () => { assert.fail('Should have the manifest'); } }); + it('should generate the hash with the sha512 algorithm', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + experimental: { + csp: { + algorithm: 'SHA-512', + }, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + + if (manifest) { + const request = new Request('http://example.com/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"]'); + assert.ok(meta.attr('content').toString().includes('sha512-')); + } else { + assert.fail('Should have the manifest'); + } + }); + + it('should generate the hash with the sha384 algorithm', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + experimental: { + csp: { + algorithm: 'SHA-384', + }, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + + if (manifest) { + const request = new Request('http://example.com/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"]'); + assert.ok(meta.attr('content').toString().includes('sha384-')); + } else { + assert.fail('Should have the manifest'); + } + }); }); From 0aae798823141b51a97204ea8a3d4bf05a4c3cff Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 08:29:14 +0100 Subject: [PATCH 02/10] Update packages/astro/src/core/build/generate.ts Co-authored-by: Florian Lefebvre --- packages/astro/src/core/build/generate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a00a99dd9e08..136cf52ec8b4 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -639,7 +639,7 @@ async function createBuildManifest( clientStyleHashes, clientScriptHashes, shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), - algorithm: getAlgorithm(settings.config), + algorithm }; } return { From c141545102a59f8f57287e95eda3f8374c682d0a Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 08:29:23 +0100 Subject: [PATCH 03/10] Update packages/astro/src/core/csp/common.ts Co-authored-by: Florian Lefebvre --- packages/astro/src/core/csp/common.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index 0cd82e013b4a..7781e97facc3 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -20,11 +20,10 @@ export function getAlgorithm(config: AstroConfig): CspAlgorithm { if (!config.experimental?.csp) { throw new Error('CSP is not enabled'); } - if (config.experimental?.csp === true) { + if (config.experimental.csp === true) { return 'SHA-256'; - } else { - return config.experimental.csp.algorithm; } + return config.experimental.csp.algorithm; } export async function trackStyleHashes( From dde5048eda462302ece7619564e78dcb1d17f8e0 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 08:35:11 +0100 Subject: [PATCH 04/10] feedback --- .../astro/src/core/config/schemas/base.ts | 19 +++++++++++++------ .../astro/src/core/config/schemas/index.ts | 7 ++++++- packages/astro/src/core/encryption.ts | 16 +--------------- packages/astro/src/types/public/config.ts | 10 +++++----- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 7f11d495b22e..58b45ec964e8 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -107,6 +107,8 @@ const highlighterTypesSchema = z .union([z.literal('shiki'), z.literal('prism')]) .default(syntaxHighlightDefaults.type); +const cspAlgorithmSchema = z.enum(['SHA-512', 'SHA-384', 'SHA-256']).optional().default('SHA-256'); + export const AstroConfigSchema = z.object({ root: z .string() @@ -474,12 +476,15 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.preserveScriptOrder), fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(), - csp: z.union([ - z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), - z.object({ - algorithm: z.enum(['SHA-512', 'SHA-384', 'SHA-256']).optional().default('SHA-256'), - }), - ]).optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), + csp: z + .union([ + z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), + z.object({ + algorithm: cspAlgorithmSchema, + }), + ]) + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.csp), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, @@ -492,4 +497,6 @@ export const AstroConfigSchema = z.object({ .default({}), }); +export type CspAlgorithm = z.infer; + export type AstroConfigType = z.infer; diff --git a/packages/astro/src/core/config/schemas/index.ts b/packages/astro/src/core/config/schemas/index.ts index f6a0289e7ae4..61546978a578 100644 --- a/packages/astro/src/core/config/schemas/index.ts +++ b/packages/astro/src/core/config/schemas/index.ts @@ -1,3 +1,8 @@ -export { AstroConfigSchema, ASTRO_CONFIG_DEFAULTS, type AstroConfigType } from './base.js'; +export { + AstroConfigSchema, + ASTRO_CONFIG_DEFAULTS, + type AstroConfigType, + type CspAlgorithm, +} from './base.js'; export { createRelativeSchema } from './relative.js'; export { AstroConfigRefinedSchema } from './refined.js'; diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index f8dc3913200a..a3dda51b1ec8 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -117,21 +117,7 @@ export async function decryptString(key: CryptoKey, encoded: string) { * @param {'sha256' | 'sha512' | 'sha384'} algorithm The algorithm to use. */ export async function generateCspDigest(data: string, algorithm: CspAlgorithm): Promise { - let hashBuffer; - switch (algorithm) { - case 'SHA-256': { - hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data)); - break; - } - case 'SHA-512': { - hashBuffer = await crypto.subtle.digest('SHA-512', encoder.encode(data)); - break; - } - case 'SHA-384': { - hashBuffer = await crypto.subtle.digest('SHA-384', encoder.encode(data)); - break; - } - } + let hashBuffer = await crypto.subtle.digest(algorithm, encoder.encode(data)); const hash = encodeBase64(new Uint8Array(hashBuffer)); diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 325749f4696b..cba439e33649 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -12,7 +12,7 @@ import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions import type { AstroFontProvider, FontFamily } from '../../assets/fonts/types.js'; import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { AssetsPrefix } from '../../core/app/types.js'; -import type { AstroConfigType } from '../../core/config/schemas/index.js'; +import type { AstroConfigType, CspAlgorithm } from '../../core/config/schemas/index.js'; import type { REDIRECT_STATUS_CODES } from '../../core/constants.js'; import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js'; import type { Logger, LoggerLevel } from '../../core/logger/core.js'; @@ -23,6 +23,8 @@ export type Locales = (string | { codes: [string, ...string[]]; path: string })[ export type { AstroFontProvider as FontProvider }; +export type { CspAlgorithm }; + type NormalizeLocales = { [K in keyof T]: T[K] extends string ? T[K] @@ -172,8 +174,6 @@ export interface ViteUserConfig extends OriginalViteUserConfig { ssr?: ViteSSROptions; } -export type CspAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512'; - // NOTE(fks): We choose to keep our hand-generated AstroUserConfig interface so that // we can add JSDoc-style documentation and link to the definition file in our repo. // However, Zod comes with the ability to auto-generate AstroConfig from the schema @@ -2244,13 +2244,13 @@ export type CspAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512'; /** * @name experimental.csp.algorithm * @type {string} - * @default `'sha256'` + * @default `'SHA-256'` * @version 5.5.x * @description * * The hashing algorithm to use for the CSP. * - * The default value is `'sha256'`. + * The default value is `'SHA-256'`. * */ algorithm?: CspAlgorithm; From a1696914c0daad07ddbd7241ce231f5516593dd1 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:14:15 +0100 Subject: [PATCH 05/10] Update packages/astro/src/core/encryption.ts Co-authored-by: Florian Lefebvre --- packages/astro/src/core/encryption.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index a3dda51b1ec8..55df3cbfc0d8 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -117,7 +117,7 @@ export async function decryptString(key: CryptoKey, encoded: string) { * @param {'sha256' | 'sha512' | 'sha384'} algorithm The algorithm to use. */ export async function generateCspDigest(data: string, algorithm: CspAlgorithm): Promise { - let hashBuffer = await crypto.subtle.digest(algorithm, encoder.encode(data)); + const hashBuffer = await crypto.subtle.digest(algorithm, encoder.encode(data)); const hash = encodeBase64(new Uint8Array(hashBuffer)); From 2bccd37815df3a037595fdfd3076a44748fd7b1f Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:14:25 +0100 Subject: [PATCH 06/10] Update packages/astro/src/core/encryption.ts Co-authored-by: Florian Lefebvre --- packages/astro/src/core/encryption.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index 55df3cbfc0d8..d8fbfaa3f1a8 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -114,7 +114,7 @@ export async function decryptString(key: CryptoKey, encoded: string) { /** * Generates an SHA-256 digest of the given string. * @param {string} data The string to hash. - * @param {'sha256' | 'sha512' | 'sha384'} algorithm The algorithm to use. + * @param {CspAlgorithm} algorithm The algorithm to use. */ export async function generateCspDigest(data: string, algorithm: CspAlgorithm): Promise { const hashBuffer = await crypto.subtle.digest(algorithm, encoder.encode(data)); From fb93fbf7ce9ac546955b5742ec36af34f7bbced1 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:15:22 +0100 Subject: [PATCH 07/10] feedback --- packages/astro/src/core/config/schemas/base.ts | 5 +---- packages/astro/src/core/csp/config.ts | 8 ++++++++ packages/astro/src/types/public/config.ts | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 packages/astro/src/core/csp/config.ts diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 58b45ec964e8..42a00a8bdf73 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -11,6 +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 { cspAlgorithmSchema } 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 @@ -107,8 +108,6 @@ const highlighterTypesSchema = z .union([z.literal('shiki'), z.literal('prism')]) .default(syntaxHighlightDefaults.type); -const cspAlgorithmSchema = z.enum(['SHA-512', 'SHA-384', 'SHA-256']).optional().default('SHA-256'); - export const AstroConfigSchema = z.object({ root: z .string() @@ -497,6 +496,4 @@ export const AstroConfigSchema = z.object({ .default({}), }); -export type CspAlgorithm = z.infer; - export type AstroConfigType = z.infer; diff --git a/packages/astro/src/core/csp/config.ts b/packages/astro/src/core/csp/config.ts new file mode 100644 index 000000000000..f3c61878326a --- /dev/null +++ b/packages/astro/src/core/csp/config.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const cspAlgorithmSchema = z + .enum(['SHA-512', 'SHA-384', 'SHA-256']) + .optional() + .default('SHA-256'); + +export type CspAlgorithm = z.infer; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index cba439e33649..f7eee823fb09 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -12,12 +12,13 @@ import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions import type { AstroFontProvider, FontFamily } from '../../assets/fonts/types.js'; import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { AssetsPrefix } from '../../core/app/types.js'; -import type { AstroConfigType, CspAlgorithm } from '../../core/config/schemas/index.js'; +import type { AstroConfigType } from '../../core/config/schemas/index.js'; import type { REDIRECT_STATUS_CODES } from '../../core/constants.js'; import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js'; import type { Logger, LoggerLevel } from '../../core/logger/core.js'; import type { EnvSchema } from '../../env/schema.js'; import type { AstroIntegration } from './integrations.js'; +import type { CspAlgorithm } from '../../core/csp/config.js'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; From d8a6663b541bfe3758c3a54f6ea31ff7e2089161 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:16:10 +0100 Subject: [PATCH 08/10] feedback --- packages/astro/src/core/config/schemas/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/astro/src/core/config/schemas/index.ts b/packages/astro/src/core/config/schemas/index.ts index 61546978a578..75689b0724c9 100644 --- a/packages/astro/src/core/config/schemas/index.ts +++ b/packages/astro/src/core/config/schemas/index.ts @@ -2,7 +2,6 @@ export { AstroConfigSchema, ASTRO_CONFIG_DEFAULTS, type AstroConfigType, - type CspAlgorithm, } from './base.js'; export { createRelativeSchema } from './relative.js'; export { AstroConfigRefinedSchema } from './refined.js'; From 0e2e524220d365e8f36f85b8a409cc0350e9b84d Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:19:00 +0100 Subject: [PATCH 09/10] address more feedback --- packages/astro/src/core/app/types.ts | 4 ---- packages/astro/src/core/build/generate.ts | 3 +-- packages/astro/src/core/build/plugins/plugin-manifest.ts | 1 - packages/astro/src/core/render-context.ts | 2 +- packages/astro/src/vite-plugin-astro-server/plugin.ts | 1 - 5 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 66d7509daa90..4acfff63effa 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -111,10 +111,6 @@ export type SSRManifestCSP = { algorithm: CspAlgorithm; clientScriptHashes: string[]; clientStyleHashes: string[]; - /** - * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag - */ - shouldInjectCspMetaTags: boolean; }; /** Public type exposed through the `astro:build:ssr` integration hook */ diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 136cf52ec8b4..48cc8efc07aa 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -638,8 +638,7 @@ async function createBuildManifest( csp = { clientStyleHashes, clientScriptHashes, - shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), - algorithm + algorithm, }; } return { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index f8d5f40f4d8e..037a0e508e23 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -289,7 +289,6 @@ async function buildManifest( const clientStyleHashes = await trackStyleHashes(internals, opts.settings, algorithm); csp = { - shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), clientStyleHashes, clientScriptHashes, algorithm, diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index c3bce14ca539..2b5ebe694baa 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -463,7 +463,7 @@ export class RenderContext { extraScriptHashes: [], propagators: new Set(), }, - shouldInjectCspMetaTags: manifest.csp?.shouldInjectCspMetaTags ?? false, + shouldInjectCspMetaTags: !!manifest.csp, clientScriptHashes: manifest.csp?.clientScriptHashes ?? [], clientStyleHashes: manifest.csp?.clientStyleHashes ?? [], cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256', diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index c66823c5e0a1..7deb62d13d63 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -179,7 +179,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest csp = { clientScriptHashes: [], clientStyleHashes: [], - shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), algorithm: getAlgorithm(settings.config), }; } From 0db9a40e6d4c735305b9bbf96613b52f246a4406 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 15 May 2025 09:19:42 +0100 Subject: [PATCH 10/10] Update packages/astro/src/core/csp/common.ts Co-authored-by: Florian Lefebvre --- packages/astro/src/core/csp/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index 7781e97facc3..61dcb12fe247 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -18,6 +18,8 @@ export function shouldTrackCspHashes(config: AstroConfig): boolean { */ export function getAlgorithm(config: AstroConfig): CspAlgorithm { if (!config.experimental?.csp) { + // A regular error is fine here because this code should never be reached + // if CSP is not enabled throw new Error('CSP is not enabled'); } if (config.experimental.csp === true) {