diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 41699440053a..e08c890ca4c2 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -165,6 +165,10 @@ function createManifest( checkOrigin: false, middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), + clientScriptHashes: manifest?.clientScriptHashes ?? [], + clientStyleHashes: manifest?.clientStyleHashes ?? [], + shouldInjectCspMetaTags: manifest?.shouldInjectCspMetaTags ?? false, + astroIslandHashes: manifest?.astroIslandHashes ?? [], }; } @@ -250,6 +254,10 @@ type AstroContainerManifest = Pick< | 'publicDir' | 'outDir' | 'cacheDir' + | 'clientScriptHashes' + | 'clientStyleHashes' + | 'shouldInjectCspMetaTags' + | 'astroIslandHashes' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index d0716332919a..c223717f04f6 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -86,6 +86,13 @@ 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; + astroIslandHashes: string[]; }; export type SSRActions = { diff --git a/packages/astro/src/core/csp-hashes.js b/packages/astro/src/core/astro-islands-hashes.ts similarity index 72% rename from packages/astro/src/core/csp-hashes.js rename to packages/astro/src/core/astro-islands-hashes.ts index bdc5499f94d0..9581e3b6456d 100644 --- a/packages/astro/src/core/csp-hashes.js +++ b/packages/astro/src/core/astro-islands-hashes.ts @@ -1,10 +1,11 @@ // This file is code-generated, please don't change it manually -export default [ +export const ASTRO_ISLAND_HASHES = [ "GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=", "HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=", "XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=", "ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=", "cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=", "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", - "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=" + "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", + "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=" ]; \ No newline at end of file diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 0627f378ffce..35bd6a0785ba 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -8,7 +8,7 @@ import { getStaticImageList, prepareAssetsGenerationEnv, } from '../../assets/build/generate.js'; -import { type BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js'; +import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import { isRelativePath, joinPaths, @@ -49,6 +49,8 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../csp/common.js'; +import { ASTRO_ISLAND_HASHES } from '../astro-islands-hashes.js'; export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) { const generatePagesTimer = performance.now(); @@ -600,8 +602,6 @@ function getPrettyRouteName(route: RouteData): string { * It creates a `SSRManifest` from the `AstroSettings`. * * Renderers needs to be pulled out from the page module emitted during the build. - * @param settings - * @param renderers */ function createBuildManifest( settings: AstroSettings, @@ -612,6 +612,15 @@ function createBuildManifest( key: Promise, ): SSRManifest { let i18nManifest: SSRManifestI18n | undefined = undefined; + + let clientStyleHashes: string[] = []; + let clientScriptHashes: string[] = []; + + if (shouldTrackCspHashes(settings.config)) { + clientScriptHashes = trackScriptHashes(internals, settings); + clientStyleHashes = trackStyleHashes(internals); + } + if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, @@ -655,5 +664,9 @@ function createBuildManifest( checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, key, + clientStyleHashes, + clientScriptHashes, + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 5c3fa6f4f9ef..0fe498cb30b0 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -23,6 +23,8 @@ import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; import { makePageDataKey } from './util.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../../csp/common.js'; +import { ASTRO_ISLAND_HASHES } from '../../astro-islands-hashes.js'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g'); @@ -275,6 +277,14 @@ function buildManifest( }; } + let clientScriptHashes: string[] = []; + let clientStyleHashes: string[] = []; + + if (shouldTrackCspHashes(settings.config)) { + clientScriptHashes = trackScriptHashes(internals, opts.settings); + clientStyleHashes = trackStyleHashes(internals); + } + return { hrefRoot: opts.settings.config.root.toString(), cacheDir: opts.settings.config.cacheDir.toString(), @@ -304,5 +314,9 @@ function buildManifest( serverIslandNameMap: Array.from(settings.serverIslandNameMap), key: encodedKey, sessionConfig: settings.config.session, + shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), + clientStyleHashes, + clientScriptHashes, + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 45a94a6f9a54..08b74c6bc997 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -103,6 +103,7 @@ export const ASTRO_CONFIG_DEFAULTS = { session: false, headingIdCompat: false, preserveScriptOrder: false, + csp: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -626,6 +627,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.preserveScriptOrder), + csp: z.boolean().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 new file mode 100644 index 000000000000..97d63c271ddf --- /dev/null +++ b/packages/astro/src/core/csp/common.ts @@ -0,0 +1,40 @@ +import type { AstroConfig } from '../../types/public/index.js'; +import type { BuildInternals } from '../build/internal.js'; +import crypto from 'node:crypto'; +import type { AstroSettings } from '../../types/astro.js'; + +export function shouldTrackCspHashes(config: AstroConfig): boolean { + return config.experimental?.csp === true; +} + +export function trackStyleHashes(internals: BuildInternals): string[] { + const clientStyleHashes: string[] = []; + for (const [_, page] of internals.pagesByViteID.entries()) { + for (const style of page.styles) { + if (style.sheet.type === 'inline') { + clientStyleHashes.push( + crypto.createHash('sha256').update(style.sheet.content).digest('base64'), + ); + } + } + } + + return clientStyleHashes; +} + +export function trackScriptHashes(internals: BuildInternals, settings: AstroSettings): string[] { + const clientScriptHashes: string[] = []; + + for (const script of internals.inlinedScripts.values()) { + clientScriptHashes.push(crypto.createHash('sha256').update(script).digest('base64')); + } + + for (const script of settings.scripts) { + const { content, stage } = script; + if (stage === 'head-inline' || stage === 'before-hydration') { + clientScriptHashes.push(crypto.createHash('sha256').update(content).digest('base64')); + } + } + + return clientScriptHashes; +} diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index c051ead985ad..93a46c6c9349 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -443,6 +443,9 @@ export class RenderContext { extraHead: [], propagators: new Set(), }, + shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags, + clientScriptHashes: manifest.clientScriptHashes, + clientStyleHashes: manifest.clientStyleHashes, }; return result; diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 63c34ab1d684..16ec1b0910bc 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -129,19 +129,21 @@ export function normalizeInjectedTypeFilename(filename: string, integrationName: return `${normalizeCodegenDir(integrationName)}${filename.replace(SAFE_CHARS_RE, '_')}`; } +interface RunHookConfigSetup { + settings: AstroSettings; + command: 'dev' | 'build' | 'preview' | 'sync'; + logger: Logger; + isRestart?: boolean; + fs?: typeof fsMod; +} + export async function runHookConfigSetup({ settings, command, logger, isRestart = false, fs = fsMod, -}: { - settings: AstroSettings; - command: 'dev' | 'build' | 'preview' | 'sync'; - logger: Logger; - isRestart?: boolean; - fs?: typeof fsMod; -}): Promise { +}: RunHookConfigSetup): Promise { // An adapter is an integration, so if one is provided add it to the list of integrations. if (settings.config.adapter) { settings.config.integrations.unshift(settings.config.adapter); diff --git a/packages/astro/src/runtime/server/astro-island-styles.ts b/packages/astro/src/runtime/server/astro-island-styles.ts new file mode 100644 index 000000000000..ca816c9204b9 --- /dev/null +++ b/packages/astro/src/runtime/server/astro-island-styles.ts @@ -0,0 +1,2 @@ +export const ISLAND_STYLES = + ''; diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 77f05dfccec6..fb22edb13647 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -1,5 +1,4 @@ import type { RenderInstruction } from './instruction.js'; - import type { SSRResult } from '../../../types/public/internal.js'; import type { HTMLBytes, HTMLString } from '../escape.js'; import { markHTMLString } from '../escape.js'; @@ -99,6 +98,7 @@ function stringifyChunk( } return ''; } + default: { throw new Error(`Unknown chunk type: ${(chunk as any).type}`); } diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 79edc9621898..bc7a70f9b965 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -51,6 +51,27 @@ export function renderAllHeadContent(result: SSRResult) { } } + const hashes = []; + + if (result.shouldInjectCspMetaTags) { + for (const scriptHash of [...result.clientScriptHashes, ...result.clientStyleHashes]) { + hashes.push( + renderElement( + 'meta', + { + props: { + 'http-equiv': 'content-security-policy', + content: scriptHash, + }, + children: '', + }, + false, + ), + ); + } + } + content += hashes.join('\n'); + return markHTMLString(content); } diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index ca9cad1fb186..4756a0adb510 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -1,8 +1,7 @@ import type { SSRResult } from '../../types/public/internal.js'; import islandScriptDev from './astro-island.prebuilt-dev.js'; import islandScript from './astro-island.prebuilt.js'; - -const ISLAND_STYLES = ``; +import { ISLAND_STYLES } from './astro-island-styles.js'; export function determineIfNeedsHydrationScript(result: SSRResult): boolean { if (result._metadata.hasHydrationScript) { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index e0088e7f842c..97b75dd1c7be 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2170,6 +2170,13 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ headingIdCompat?: boolean; + + /** + * + */ + // TODO: add docs once we are reaching the end + csp?: boolean, + /** * @name experimental.preserveScriptOrder * @type {boolean} diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index a2c1b01d6e8f..988803928a90 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -246,6 +246,12 @@ export interface SSRResult { trailingSlash: AstroConfig['trailingSlash']; key: Promise; _metadata: SSRMetadata; + /** + * Whether Astro should inject the CSP tag into the head of the component. + */ + shouldInjectCspMetaTags: boolean; + clientScriptHashes: string[]; + clientStyleHashes: string[]; } /** diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index bed0069b678a..b61bf76a3a7b 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -25,6 +25,8 @@ import { DevPipeline } from './pipeline.js'; import { handleRequest } from './request.js'; import { setRouteError } from './server-state.js'; import { trailingSlashMiddleware } from './trailing-slash.js'; +import { ASTRO_ISLAND_HASHES } from '../core/astro-islands-hashes.js'; +import { shouldTrackCspHashes } from '../core/csp/common.js'; export interface AstroPluginOptions { settings: AstroSettings; @@ -100,8 +102,7 @@ export default function createVitePluginAstroServer({ }); const store = localStorage.getStore(); if (store instanceof IncomingMessage) { - const request = store; - setRouteError(controller.state, request.url!, error); + setRouteError(controller.state, store.url!, error); } const { errorWithMetadata } = recordServerError(loader, settings.config, pipeline, error); setTimeout( @@ -207,5 +208,9 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest }; }, sessionConfig: settings.config.experimental.session ? settings.config.session : undefined, + clientScriptHashes: [], + clientStyleHashes: [], + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js new file mode 100644 index 000000000000..23ae039a305d --- /dev/null +++ b/packages/astro/test/csp.test.js @@ -0,0 +1,42 @@ +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; +import assert from 'node:assert/strict'; +import * as cheerio from 'cheerio'; + +describe('CSP', () => { + let app; + /** + * @type {import('../dist/core/build/types.js').SSGManifest} + */ + let manifest; + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should contain the meta style hashes when CSS is imported from Astro component', async () => { + if (manifest) { + const request = new Request('http://example.com/index.html'); + const response = await app.render(request); + const $ = cheerio.load(await response.text()); + + for (const hash of manifest.clientStyleHashes) { + let meta = $('meta[http-equiv="Content-Security-Policy"][content="' + hash + '"]'); + assert.equal(meta.length, 1, `Should have a CSP meta tag for ${hash}`); + } + } else { + assert.fail('Should have the manifest'); + } + }); +}); diff --git a/packages/astro/test/fixtures/csp/astro.config.mjs b/packages/astro/test/fixtures/csp/astro.config.mjs new file mode 100644 index 000000000000..7184336cde47 --- /dev/null +++ b/packages/astro/test/fixtures/csp/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { + csp: true, + } +}); + diff --git a/packages/astro/test/fixtures/csp/package.json b/packages/astro/test/fixtures/csp/package.json new file mode 100644 index 000000000000..60390ccd8f87 --- /dev/null +++ b/packages/astro/test/fixtures/csp/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/csp", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/csp/src/pages/index.astro b/packages/astro/test/fixtures/csp/src/pages/index.astro new file mode 100644 index 000000000000..e54b6c325b75 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +import "./index.css" +--- + + + + + + Index + + +
+

Index

+
+ + diff --git a/packages/astro/test/fixtures/csp/src/pages/index.css b/packages/astro/test/fixtures/csp/src/pages/index.css new file mode 100644 index 000000000000..3496bc852199 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/index.css @@ -0,0 +1,5 @@ +.content { + display: flex; + background: red; + border: 1px solid blue; +} diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index b8655a9d6458..1b0c54845c02 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -26,6 +26,7 @@ export default function ({ setEntryPoints, setMiddlewareEntryPoint, setRoutes, + setManifest, env, } = {}) { return { @@ -107,7 +108,7 @@ export default function ({ exports: ['manifest', 'createApp'], supportedAstroFeatures: { serverOutput: 'stable', - envGetSecret: 'experimental', + envGetSecret: 'stable', staticOutput: 'stable', hybridOutput: 'stable', assets: 'stable', @@ -119,13 +120,16 @@ export default function ({ ...extendAdapter, }); }, - 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint }) => { + 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint, manifest }) => { if (setEntryPoints) { setEntryPoints(entryPoints); } if (setMiddlewareEntryPoint) { setMiddlewareEntryPoint(middlewareEntryPoint); } + if (setManifest) { + setManifest(manifest); + } }, 'astro:build:done': ({ routes }) => { if (setRoutes) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc19ca4dcf6e..2f209ae7c98e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2781,6 +2781,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/csp: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/csrf-check-origin: dependencies: astro: diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index e7bca657a3c6..1ee505500308 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -10,6 +10,8 @@ function escapeTemplateLiterals(str) { return str.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${'); } +const ASTRO_ISLAND_STYLE_REGEX = /'([^']*)'/; + export default async function prebuild(...args) { let buildToString = args.indexOf('--to-string'); if (buildToString !== -1) { @@ -116,16 +118,28 @@ export default \`${generatedCode}\`;`; hashes.push(hash); } } + for (const entrypoint of entryPoints) { + await prebuildFile(entrypoint); + } - await Promise.all(entryPoints.map(prebuildFile)); + const fileContent = await fs.promises.readFile( + new URL('../../packages/astro/src/runtime/server/astro-island-styles.ts', import.meta.url), + 'utf-8', + ); + const styleContent = fileContent.match(ASTRO_ISLAND_STYLE_REGEX)[1]; + hashes.push(crypto.createHash('sha256').update(styleContent).digest('base64')); hashes.sort(); const entries = hashes.map((hash) => `"${hash}"`); const content = `// This file is code-generated, please don't change it manually -export default [ +export const ASTRO_ISLAND_HASHES = [ ${entries.join(',\n ')} ];`; await fs.promises.writeFile( - path.join(fileURLToPath(import.meta.url), '../../../packages/astro/src/core', 'csp-hashes.js'), + path.join( + fileURLToPath(import.meta.url), + '../../../packages/astro/src/core', + 'astro-islands-hashes.ts', + ), content, 'utf-8', );