diff --git a/package.json b/package.json index 145078de4e71..d79a0e38fa2e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:e2e:match": "cd packages/astro && pnpm playwright install chromium firefox && pnpm run test:e2e:match", "test:e2e:hosts": "turbo run test:hosted", "benchmark": "astro-benchmark", - "lint": "biome lint && eslint . --report-unused-disable-directives-severity=warn && knip", + "lint": "biome lint && knip && eslint . --report-unused-disable-directives-severity=warn", "lint:ci": "biome ci --formatter-enabled=false --organize-imports-enabled=false --reporter=github && eslint . --report-unused-disable-directives-severity=warn && knip", "lint:fix": "biome lint --write --unsafe", "publint": "pnpm -r --filter=astro --filter=create-astro --filter=\"@astrojs/*\" --no-bail exec publint", diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 48cc8efc07aa..e452494e4acc 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -30,6 +30,8 @@ import type { import type { SSRActions, SSRManifestCSP, SSRManifest, SSRManifestI18n } from '../app/types.js'; import { getAlgorithm, + getScriptHashes, + getStyleHashes, shouldTrackCspHashes, trackScriptHashes, trackStyleHashes, @@ -630,10 +632,16 @@ async function createBuildManifest( }; } - if (shouldTrackCspHashes(settings.config)) { - const algorithm = getAlgorithm(settings.config); - const clientScriptHashes = await trackScriptHashes(internals, settings, algorithm); - const clientStyleHashes = await trackStyleHashes(internals, settings, algorithm); + if (shouldTrackCspHashes(settings.config.experimental.csp)) { + const algorithm = getAlgorithm(settings.config.experimental.csp); + const clientScriptHashes = [ + ...getScriptHashes(settings.config.experimental.csp), + ...(await trackScriptHashes(internals, settings, algorithm)), + ]; + const clientStyleHashes = [ + ...getStyleHashes(settings.config.experimental.csp), + ...(await trackStyleHashes(internals, settings, algorithm)), + ]; csp = { clientStyleHashes, diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 037a0e508e23..439b3f5ca59f 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -14,6 +14,8 @@ import type { } from '../../app/types.js'; import { getAlgorithm, + getScriptHashes, + getStyleHashes, shouldTrackCspHashes, trackScriptHashes, trackStyleHashes, @@ -283,10 +285,16 @@ async function buildManifest( let csp = undefined; - if (shouldTrackCspHashes(settings.config)) { - const algorithm = getAlgorithm(settings.config); - const clientScriptHashes = await trackScriptHashes(internals, opts.settings, algorithm); - const clientStyleHashes = await trackStyleHashes(internals, opts.settings, algorithm); + if (shouldTrackCspHashes(settings.config.experimental.csp)) { + const algorithm = getAlgorithm(settings.config.experimental.csp); + const clientScriptHashes = [ + ...getScriptHashes(settings.config.experimental.csp), + ...(await trackScriptHashes(internals, settings, algorithm)), + ]; + const clientStyleHashes = [ + ...getStyleHashes(settings.config.experimental.csp), + ...(await trackStyleHashes(internals, settings, algorithm)), + ]; csp = { clientStyleHashes, diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 42a00a8bdf73..845e27b47f95 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -480,6 +480,8 @@ export const AstroConfigSchema = z.object({ z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), z.object({ algorithm: cspAlgorithmSchema, + styleHashes: z.array(z.string()).optional(), + scriptHashes: z.array(z.string()).optional(), }), ]) .optional() diff --git a/packages/astro/src/core/config/schemas/refined.ts b/packages/astro/src/core/config/schemas/refined.ts index c14e18f12c92..61d9e93689dd 100644 --- a/packages/astro/src/core/config/schemas/refined.ts +++ b/packages/astro/src/core/config/schemas/refined.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { AstroConfig } from '../../../types/public/config.js'; +import { ALGORITHM_VALUES } from '../../csp/config.js'; export const AstroConfigRefinedSchema = z.custom().superRefine((config, ctx) => { if ( @@ -203,4 +204,35 @@ export const AstroConfigRefinedSchema = z.custom().superRefine((con } } } + + if (config.experimental.csp && typeof config.experimental.csp === 'object') { + const { scriptHashes, styleHashes } = config.experimental.csp; + if (scriptHashes) { + for (const hash of scriptHashes) { + for (const allowedValue of ALGORITHM_VALUES) { + if (!hash.startsWith(allowedValue)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `**scriptHashes** property "${hash}" must start with with one of following values: ${ALGORITHM_VALUES.join(', ')}.`, + path: ['experimental', 'csp', 'scriptHashes'], + }); + } + } + } + } + + if (styleHashes) { + for (const hash of styleHashes) { + for (const allowedValue of ALGORITHM_VALUES) { + if (!hash.startsWith(allowedValue)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `**styleHashes** property "${hash}" must start with with one of following values: ${ALGORITHM_VALUES.join(', ')}.`, + path: ['experimental', 'csp', 'styleHashes'], + }); + } + } + } + } + } }); diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index 61dcb12fe247..b3e56f9bab11 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -8,24 +8,33 @@ import type { AstroConfig, CspAlgorithm } from '../../types/public/index.js'; import type { BuildInternals } from '../build/internal.js'; import { generateCspDigest } from '../encryption.js'; -export function shouldTrackCspHashes(config: AstroConfig): boolean { - return config.experimental?.csp === true || typeof config.experimental?.csp === 'object'; +type EnabledCsp = Exclude; + +export function shouldTrackCspHashes(csp: any): csp is EnabledCsp { + return csp === true || typeof 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) { - // 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) { +export function getAlgorithm(csp: EnabledCsp): CspAlgorithm { + if (csp === true) { return 'SHA-256'; } - return config.experimental.csp.algorithm; + return csp.algorithm; +} + +export function getScriptHashes(csp: EnabledCsp): string[] { + if (csp === true) { + return []; + } else { + return csp.scriptHashes ?? []; + } +} + +export function getStyleHashes(csp: EnabledCsp): string[] { + if (csp === true) { + return []; + } else { + return csp.styleHashes ?? []; + } } export async function trackStyleHashes( diff --git a/packages/astro/src/core/csp/config.ts b/packages/astro/src/core/csp/config.ts index f3c61878326a..249f54796d2f 100644 --- a/packages/astro/src/core/csp/config.ts +++ b/packages/astro/src/core/csp/config.ts @@ -1,8 +1,31 @@ import { z } from 'zod'; +type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends ( + arg: infer I, +) => void + ? I + : never; + +type UnionToTuple = UnionToIntersection T> extends ( + _: never, +) => infer W + ? [...UnionToTuple>, W] + : []; + +const ALGORITHMS = { + 'SHA-256': 'sha256-', + 'SHA-384': 'sha384-', + 'SHA-512': 'sha512-', +} as const; + +type Algorithms = typeof ALGORITHMS; + +export type CspAlgorithm = keyof Algorithms; +export type CspAlgorithmValue = Algorithms[keyof Algorithms]; + +export const ALGORITHM_VALUES = Object.values(ALGORITHMS) as UnionToTuple; + export const cspAlgorithmSchema = z - .enum(['SHA-512', 'SHA-384', 'SHA-256']) + .enum(Object.keys(ALGORITHMS) as UnionToTuple) .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 f7eee823fb09..9f7ef6c4f74b 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -18,7 +18,7 @@ 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'; +import type { CspAlgorithm, CspAlgorithmValue } from '../../core/csp/config.js'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; @@ -2244,7 +2244,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { | { /** * @name experimental.csp.algorithm - * @type {string} + * @type {"SHA-256" | "SHA-384" | "SHA-512"} * @default `'SHA-256'` * @version 5.5.x * @description @@ -2255,6 +2255,34 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * */ algorithm?: CspAlgorithm; + + /** + * @name experimental.csp.styleHashes + * @type {string[]} + * @default `[]` + * @version 5.5.x + * @description + * + * A list of style hashes to include in all pages. These hashes are added to the `style-src` policy. + * + * The default value is `[]`. + * + */ + styleHashes?: `${CspAlgorithmValue}${string}`[]; + + /** + * @name experimental.csp.scriptHashes + * @type {string[]} + * @default `[]` + * @version 5.5.x + * @description + * + * A list of script hashes to include in all pages. These hashes are added to the `script-src` policy. + * + * The default value is `[]`. + * + */ + scriptHashes?: `${CspAlgorithmValue}${string}`[]; }; /** diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 7deb62d13d63..96da8f423c56 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -5,7 +5,12 @@ import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; import type { SSRManifestCSP, SSRManifest, SSRManifestI18n } from '../core/app/types.js'; -import { getAlgorithm, shouldTrackCspHashes } from '../core/csp/common.js'; +import { + getAlgorithm, + getScriptHashes, + getStyleHashes, + 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'; @@ -175,11 +180,11 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest }; } - if (shouldTrackCspHashes(settings.config)) { + if (shouldTrackCspHashes(settings.config.experimental.csp)) { csp = { - clientScriptHashes: [], - clientStyleHashes: [], - algorithm: getAlgorithm(settings.config), + clientScriptHashes: getScriptHashes(settings.config.experimental.csp), + clientStyleHashes: getStyleHashes(settings.config.experimental.csp), + algorithm: getAlgorithm(settings.config.experimental.csp), }; } diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 3516b0f1f2ee..3456408bfcf2 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -102,4 +102,38 @@ describe('CSP', () => { assert.fail('Should have the manifest'); } }); + + it('should render hashes provided by the user', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + experimental: { + csp: { + styleHashes: ['sha512-hash1', 'sha384-hash2'], + scriptHashes: ['sha512-hash3', 'sha384-hash4'], + }, + }, + }); + 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-hash2')); + assert.ok(meta.attr('content').toString().includes('sha384-hash4')); + assert.ok(meta.attr('content').toString().includes('sha512-hash1')); + assert.ok(meta.attr('content').toString().includes('sha512-hash3')); + } else { + assert.fail('Should have the manifest'); + } + }); }); diff --git a/packages/astro/test/types/define-config.ts b/packages/astro/test/types/define-config.ts index 504d2b0bcb46..d31f5bfb7900 100644 --- a/packages/astro/test/types/define-config.ts +++ b/packages/astro/test/types/define-config.ts @@ -174,4 +174,27 @@ describe('defineConfig()', () => { }, ); }); + + it('Validates CSP hashes', () => { + defineConfig({ + experimental: { + csp: { + scriptHashes: [ + 'sha256-xx', + 'sha384-xx', + 'sha512-xx', + // @ts-expect-error doesn't have the correct prefix + 'fancy-1234567890', + ], + styleHashes: [ + 'sha256-xx', + 'sha384-xx', + 'sha512-xx', + // @ts-expect-error doesn't have the correct prefix + 'fancy-1234567890', + ], + }, + }, + }); + }); }); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 9c8f6d44b8ad..6b22d53d5152 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -479,4 +479,40 @@ describe('Config Validation', () => { ); }); }); + + describe('csp', () => { + it('should throw an error if incorrect scriptHashes are passed', async () => { + let configError = await validateConfig({ + experimental: { + csp: { + scriptHashes: ['fancy-1234567890'], + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + '**scriptHashes** property "fancy-1234567890" must start with with one of following values: sha256-, sha384-, sha512-.', + ), + true, + ); + }); + + it('should throw an error if incorrect styleHashes are passed', async () => { + let configError = await validateConfig({ + experimental: { + csp: { + styleHashes: ['fancy-1234567890'], + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + '**styleHashes** property "fancy-1234567890" must start with with one of following values: sha256-, sha384-, sha512-.', + ), + true, + ); + }); + }); });