diff --git a/.changeset/rare-ducks-sip.md b/.changeset/rare-ducks-sip.md new file mode 100644 index 000000000000..76fa5b0a7c64 --- /dev/null +++ b/.changeset/rare-ducks-sip.md @@ -0,0 +1,35 @@ +--- +'astro': patch +--- + +Adds a new Astro Adapter Feature called `experimentalStaticHeaders` to allow your adapter to receive the `Headers` for rendered static pages. + +Adapters that enable support for this feature can access header values directly, affecting their handling of some Astro features such as Content Security Policy (CSP). For example, Astro will no longer serve the CSP `` element in static pages to adapters with this support. + +Astro will serve the value of the header inside a map that can be retrieved from the hook `astro:build:generated`. Adapters can read this mapping and use their hosting headers capabilities to create a configuration file. + +A new field called `experimentalRouteToHeaders` will contain a map of `Map` where the `Headers` type contains the headers emitted by the rendered static route. + +To enable support for this experimental Astro Adapter Feature, add it to your `adapterFeatures` in your adapter config: + +```js +// my-adapter.mjs +export default function createIntegration() { + return { + name: '@example/my-adapter', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: '@example/my-adapter', + serverEntrypoint: '@example/my-adapter/server.js', + adapterFeatures: { + experimentalStaticHeaders: true + } + }); + }, + }, + }; +} +``` + +See the [Adapter API docs](https://docs.astro.build/en/reference/adapter-reference/#adapter-features) for more information about providing adapter features. diff --git a/.changeset/tough-parks-fly.md b/.changeset/tough-parks-fly.md new file mode 100644 index 000000000000..8ab7b845ba61 --- /dev/null +++ b/.changeset/tough-parks-fly.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where the experimental CSP `meta` element wasn't placed in the `` element as early as possible, causing these policies to not apply to styles and scripts that came before the `meta` element. diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index dcb67eb3a2ce..2a761045ef13 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -109,6 +109,7 @@ export type SSRManifestI18n = { }; export type SSRManifestCSP = { + cspDestination: 'adapter' | 'meta' | 'header' | undefined; algorithm: CspAlgorithm; scriptHashes: string[]; scriptResources: string[]; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 3ac79219588e..6e5292b1f095 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -10,9 +10,9 @@ import type { SSRLoadedRenderer, SSRManifest, SSRResult, + SSRActions, } from '../types/public/internal.js'; import { createOriginCheckMiddleware } from './app/middlewares.js'; -import type { SSRActions } from './app/types.js'; import { ActionNotFoundError } from './errors/errors-data.js'; import { AstroError } from './errors/index.js'; import type { Logger } from './logger/core.js'; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 3c8dd4994669..648e2efcc819 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -16,7 +16,7 @@ import { removeTrailingForwardSlash, } from '../../core/path.js'; import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js'; -import { runHookBuildGenerated } from '../../integrations/hooks.js'; +import { runHookBuildGenerated, toIntegrationResolvedRoute } from '../../integrations/hooks.js'; import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings, ComponentInstance } from '../../types/astro.js'; import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js'; @@ -62,6 +62,7 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; +import type { IntegrationResolvedRoute } from '../../types/public/index.js'; export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) { const generatePagesTimer = performance.now(); @@ -102,6 +103,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`); const builtPaths = new Set(); const pagesToGenerate = pipeline.retrieveRoutesToGenerate(); + const routeToHeaders = new Map(); if (ssr) { for (const [pageData, filePath] of pagesToGenerate) { if (pageData.route.prerender) { @@ -116,13 +118,13 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath); const ssrEntry = ssrEntryPage as SinglePageBuiltModule; - await generatePage(pageData, ssrEntry, builtPaths, pipeline); + await generatePage(pageData, ssrEntry, builtPaths, pipeline, routeToHeaders); } } } else { for (const [pageData, filePath] of pagesToGenerate) { const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath); - await generatePage(pageData, entry, builtPaths, pipeline); + await generatePage(pageData, entry, builtPaths, pipeline, routeToHeaders); } } logger.info( @@ -219,7 +221,11 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil delete globalThis?.astroAsset?.addStaticImage; } - await runHookBuildGenerated({ settings: options.settings, logger }); + await runHookBuildGenerated({ + settings: options.settings, + logger, + experimentalRouteToHeaders: routeToHeaders, + }); } const THRESHOLD_SLOW_RENDER_TIME_MS = 500; @@ -229,6 +235,7 @@ async function generatePage( ssrEntry: SinglePageBuiltModule, builtPaths: Set, pipeline: BuildPipeline, + routeToHeaders: Map, ) { // prepare information we need const { config, logger } = pipeline; @@ -275,7 +282,7 @@ async function generatePage( logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false); } - const created = await generatePath(path, pipeline, generationOptions, route); + const created = await generatePath(path, pipeline, generationOptions, route, routeToHeaders); const timeEnd = performance.now(); const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS; @@ -493,6 +500,7 @@ async function generatePath( pipeline: BuildPipeline, gopts: GeneratePathOptions, route: RouteData, + routeToHeaders: Map, ): Promise { const { mod } = gopts; const { config, logger, options } = pipeline; @@ -562,6 +570,13 @@ async function generatePath( throw err; } + if ( + pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders && + pipeline.settings.config.experimental?.csp + ) { + routeToHeaders.set(toIntegrationResolvedRoute(route), response.headers); + } + if (response.status >= 300 && response.status < 400) { // Adapters may handle redirects themselves, turning off Astro's redirect handling using `config.build.redirects` in the process. // In that case, we skip rendering static files for the redirect routes. @@ -659,6 +674,9 @@ async function createBuildManifest( ]; csp = { + cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders + ? 'adapter' + : undefined, styleHashes, styleResources: getStyleResources(settings.config.experimental.csp), scriptHashes, @@ -681,7 +699,7 @@ async function createBuildManifest( entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()), inlinedScripts: internals.inlinedScripts, routes: [], - adapterName: '', + adapterName: settings.adapter?.name ?? '', clientDirectives: settings.clientDirectives, compressHTML: settings.config.compressHTML, renderers, diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 0b20de7ef001..f6aa6529b97e 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -321,6 +321,9 @@ async function buildManifest( ]; csp = { + cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders + ? 'adapter' + : undefined, scriptHashes, scriptResources: getScriptResources(settings.config.experimental.csp), styleHashes, diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 5e4981badbc6..9ad64fed0c27 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -454,11 +454,6 @@ export class RenderContext { }, } satisfies AstroGlobal['response']; - let cspDestination: 'meta' | 'header' = 'meta'; - if (!routeData.prerender) { - cspDestination = 'header'; - } - // Create the result object that will be passed into the renderPage function. // This object starts here as an empty shell (not yet the result) but then // calling the render() function will populate the object with scripts, styles, etc. @@ -501,7 +496,7 @@ export class RenderContext { extraScriptHashes: [], propagators: new Set(), }, - cspDestination, + cspDestination: manifest.csp?.cspDestination ?? (routeData.prerender ? 'meta' : 'header'), shouldInjectCspMetaTags: !!manifest.csp, cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256', // The following arrays must be cloned, otherwise they become mutable across routes. diff --git a/packages/astro/src/integrations/features-validation.ts b/packages/astro/src/integrations/features-validation.ts index c0a51ad655f7..058758627311 100644 --- a/packages/astro/src/integrations/features-validation.ts +++ b/packages/astro/src/integrations/features-validation.ts @@ -1,4 +1,3 @@ -import { shouldTrackCspHashes } from '../core/csp/common.js'; import type { Logger } from '../core/logger/core.js'; import type { AstroSettings } from '../types/astro.js'; import type { @@ -39,7 +38,6 @@ export function validateSupportedFeatures( i18nDomains = AdapterFeatureStability.UNSUPPORTED, envGetSecret = AdapterFeatureStability.UNSUPPORTED, sharpImageService = AdapterFeatureStability.UNSUPPORTED, - cspHeader = AdapterFeatureStability.UNSUPPORTED, } = featureMap; const validationResult: ValidationResult = {}; @@ -95,17 +93,6 @@ export function validateSupportedFeatures( () => settings.config?.image?.service?.entrypoint === 'astro/assets/services/sharp', ); - validationResult.cspHeader = validateSupportKind( - cspHeader, - adapterName, - logger, - 'cspHeader', - () => - settings?.config?.experimental?.csp - ? shouldTrackCspHashes(settings.config.experimental.csp) - : false, - ); - return validationResult; } diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index eb9c797cb2b0..fee4db1af5c7 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -583,9 +583,11 @@ export async function runHookBuildSsr({ export async function runHookBuildGenerated({ settings, logger, + experimentalRouteToHeaders, }: { settings: AstroSettings; logger: Logger; + experimentalRouteToHeaders: Map; }) { const dir = settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir; @@ -595,7 +597,7 @@ export async function runHookBuildGenerated({ integration, hookName: 'astro:build:generated', logger, - params: () => ({ dir }), + params: () => ({ dir, experimentalRouteToHeaders }), }); } } @@ -679,7 +681,7 @@ export async function runHookRoutesResolved({ } } -function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute { +export function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute { return { isPrerendered: route.prerender, entrypoint: route.component, diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 4a871b10899e..05003966c495 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -16,6 +16,20 @@ const uniqueElements = (item: any, index: number, all: any[]) => { export function renderAllHeadContent(result: SSRResult) { result._metadata.hasRenderedHead = true; + let content = ''; + if (result.shouldInjectCspMetaTags && result.cspDestination === 'meta') { + content += renderElement( + 'meta', + { + props: { + 'http-equiv': 'content-security-policy', + content: renderCspContent(result), + }, + children: '', + }, + false, + ); + } const styles = Array.from(result.styles) .filter(uniqueElements) .map((style) => @@ -44,7 +58,7 @@ export function renderAllHeadContent(result: SSRResult) { // consist of CSS modules which should naturally take precedence over CSS styles, so the // order will still work. In prod, all CSS are stylesheet links. // In the future, it may be better to have only an array of head elements to avoid these assumptions. - let content = styles.join('\n') + links.join('\n') + scripts.join('\n'); + content += styles.join('\n') + links.join('\n') + scripts.join('\n'); if (result._metadata.extraHead.length > 0) { for (const part of result._metadata.extraHead) { @@ -52,20 +66,6 @@ export function renderAllHeadContent(result: SSRResult) { } } - if (result.cspDestination === 'meta') { - content += renderElement( - 'meta', - { - props: { - 'http-equiv': 'content-security-policy', - content: renderCspContent(result), - }, - children: '', - }, - false, - ); - } - return markHTMLString(content); } diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index 61310b115fee..f00f419f5d33 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -36,7 +36,7 @@ export async function renderPage( ['Content-Type', 'text/html'], ['Content-Length', bytes.byteLength.toString()], ]); - if (result.cspDestination === 'header') { + if (result.cspDestination === 'header' || result.cspDestination === 'adapter') { headers.set('content-security-policy', renderCspContent(result)); } return new Response(bytes, { @@ -78,7 +78,7 @@ export async function renderPage( // Create final response from body const init = result.response; const headers = new Headers(init.headers); - if (result.shouldInjectCspMetaTags && result.cspDestination === 'header') { + if (result.shouldInjectCspMetaTags && result.cspDestination === 'header' || result.cspDestination === 'adapter') { headers.set('content-security-policy', renderCspContent(result)); } // For non-streaming, convert string to byte array to calculate Content-Length diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts index 4dc4d550715b..1af38206e7eb 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -89,6 +89,17 @@ export interface AstroAdapterFeatures { * Determine the type of build output the adapter is intended for. Defaults to `server`; */ buildOutput?: 'static' | 'server'; + + /** + * If supported by the adapter and enabled, Astro won't add any `` tags + * in the static pages, instead it will return a mapping in the + * `astro:build:generated` hook, so adapters can consume them and add them inside + * their hosting headers configuration file. + * + * NOTE: the semantics and list of headers might change until the feature + * is out of experimental + */ + experimentalStaticHeaders?: boolean; } export interface AstroAdapter { @@ -136,12 +147,6 @@ export type AstroAdapterFeatureMap = { * The adapter supports image transformation using the built-in Sharp image service */ sharpImageService?: AdapterSupport; - - /** - * The adapter is able to provide CSP hashes using the Response header `Content-Security-Policy`. Either via hosting configuration - * for static pages or at runtime using `Response` headers for dynamic pages. - */ - cspHeader?: AdapterSupport; }; /** @@ -233,6 +238,7 @@ export interface BaseIntegrationHooks { 'astro:build:generated': (options: { dir: URL; logger: AstroIntegrationLogger; + experimentalRouteToHeaders: Map; }) => void | Promise; 'astro:build:done': (options: { pages: { pathname: string }[]; diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index 256fafc96977..8dd1f7d726b2 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -9,7 +9,7 @@ import type { AstroConfig, RedirectConfig } from './config.js'; import type { AstroGlobal, AstroGlobalPartial } from './context.js'; import type { AstroRenderer } from './integrations.js'; -export type { SSRManifest } from '../../core/app/types.js'; +export type { SSRManifest, SSRManifestCSP, SSRActions } from '../../core/app/types.js'; export interface NamedSSRLoadedRendererValue extends SSRLoadedRendererValue { name: string; @@ -248,9 +248,20 @@ export interface SSRResult { key: Promise; _metadata: SSRMetadata; /** - * Whether Astro should inject the CSP tag into the head of the component. + * `header`: + * - for static pages + * - Response header for dynamic pages + * + * `meta`: + * - for all pages + * + * `adapter`: + * - nothing for static pages (the adapter does this) + * - Response header for dynamic pages */ - cspDestination: 'header' | 'meta'; + // NOTE: we use a different type here because at runtime we must provide a value, which is + // eventually computed from RouteData.prerender + cspDestination: NonNullable; shouldInjectCspMetaTags: boolean; cspAlgorithm: SSRManifestCSP['algorithm']; scriptHashes: SSRManifestCSP['scriptHashes']; diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 3e310ad8552a..a085d063a645 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -186,6 +186,9 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest if (shouldTrackCspHashes(settings.config.experimental.csp)) { csp = { + cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders + ? 'adapter' + : undefined, scriptHashes: getScriptHashes(settings.config.experimental.csp), scriptResources: getScriptResources(settings.config.experimental.csp), styleHashes: getStyleHashes(settings.config.experimental.csp), @@ -210,7 +213,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest assets: new Set(), entryModules: {}, routes: [], - adapterName: settings?.adapter?.name || '', + adapterName: settings?.adapter?.name ?? '', clientDirectives: settings.clientDirectives, renderers: [], base: settings.config.base, diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index ea1a5601e8da..5cd482215934 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -52,7 +52,8 @@ describe('CSP', () => { if (manifest) { const request = new Request('http://example.com/index.html'); const response = await app.render(request); - const $ = cheerio.load(await response.text()); + const html = await response.text(); + const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); for (const hash of manifest.csp.scriptHashes) { @@ -66,7 +67,6 @@ describe('CSP', () => { it('should generate the hash with the sha512 algorithm', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { algorithm: 'SHA-512', @@ -74,11 +74,7 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -88,7 +84,6 @@ describe('CSP', () => { it('should generate the hash with the sha384 algorithm', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { algorithm: 'SHA-384', @@ -96,11 +91,8 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -110,7 +102,6 @@ describe('CSP', () => { it('should render hashes provided by the user', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { styleDirective: { @@ -123,11 +114,8 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -140,7 +128,6 @@ describe('CSP', () => { it('should contain the additional directives', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { directives: ["img-src 'self' 'https://example.com'"], @@ -148,11 +135,8 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -162,7 +146,6 @@ describe('CSP', () => { it('should contain the custom resources for "script-src" and "style-src"', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { styleDirective: { @@ -175,11 +158,8 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -200,14 +180,10 @@ describe('CSP', () => { it('allows injecting custom script resources and hashes based on pages', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), }); 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 html = await fixture.readFile('/scripts/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -223,18 +199,9 @@ describe('CSP', () => { 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(); + const html = await fixture.readFile('/styles/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -248,7 +215,6 @@ describe('CSP', () => { it('allows add `strict-dynamic` when enabled', async () => { fixture = await loadFixture({ root: './fixtures/csp/', - adapter: testAdapter(), experimental: { csp: { scriptDirective: { @@ -258,11 +224,7 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); - - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); + const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const meta = $('meta[http-equiv="Content-Security-Policy"]'); @@ -271,7 +233,7 @@ describe('CSP', () => { it('should serve hashes via headers for dynamic pages, when the strategy is "auto"', async () => { fixture = await loadFixture({ - root: './fixtures/csp/', + root: './fixtures/csp-adapter/', adapter: testAdapter(), experimental: { csp: true, @@ -298,4 +260,32 @@ describe('CSP', () => { const meta = $('meta[http-equiv="Content-Security-Policy"]'); assert.equal(meta.attr('content'), undefined, 'meta tag should not be present'); }); + + it('should return CSP header inside a hook', async () => { + let routeToHeaders; + fixture = await loadFixture({ + root: './fixtures/csp-adapter/', + adapter: testAdapter({ + staticHeaders: true, + setRouteToHeaders(payload) { + routeToHeaders = payload; + }, + }), + experimental: { + csp: true, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + + assert.equal( + routeToHeaders.size, + 4, + 'expected four routes: /, /scripts, /title/foo, /title/bar', + ); + + for (const headers of routeToHeaders.values()) { + assert.ok(headers.has('content-security-policy'), 'should have a CSP header'); + } + }); }); diff --git a/packages/astro/test/fixtures/csp-adapter/astro.config.mjs b/packages/astro/test/fixtures/csp-adapter/astro.config.mjs new file mode 100644 index 000000000000..52144d5285b3 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/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-adapter/package.json b/packages/astro/test/fixtures/csp-adapter/package.json new file mode 100644 index 000000000000..78a3a8b17310 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/csp-adapter", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/csp-adapter/src/components/Text.jsx b/packages/astro/test/fixtures/csp-adapter/src/components/Text.jsx new file mode 100644 index 000000000000..5317786a7d94 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/src/components/Text.jsx @@ -0,0 +1,5 @@ + + +export function Text() { + return "Text" +} diff --git a/packages/astro/test/fixtures/csp-adapter/src/pages/[title].astro b/packages/astro/test/fixtures/csp-adapter/src/pages/[title].astro new file mode 100644 index 000000000000..fe289397a4fe --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/src/pages/[title].astro @@ -0,0 +1,28 @@ +--- +import type { GetStaticPaths } from "astro"; + +export const prerender = true + +export const getStaticPaths = (() => { + return [ + { params: { title: 'Foo' } }, + { params: { title: 'Bar' } }, + ]; +}); + +const { title } = Astro.params; +--- + + + + + + + {title} + + +
+

{title}

+
+ + diff --git a/packages/astro/test/fixtures/csp-adapter/src/pages/index.astro b/packages/astro/test/fixtures/csp-adapter/src/pages/index.astro new file mode 100644 index 000000000000..e54b6c325b75 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +import "./index.css" +--- + + + + + + Index + + +
+

Index

+
+ + diff --git a/packages/astro/test/fixtures/csp-adapter/src/pages/index.css b/packages/astro/test/fixtures/csp-adapter/src/pages/index.css new file mode 100644 index 000000000000..3496bc852199 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/src/pages/index.css @@ -0,0 +1,5 @@ +.content { + display: flex; + background: red; + border: 1px solid blue; +} diff --git a/packages/astro/test/fixtures/csp-adapter/src/pages/scripts.astro b/packages/astro/test/fixtures/csp-adapter/src/pages/scripts.astro new file mode 100644 index 000000000000..3e90aca551e5 --- /dev/null +++ b/packages/astro/test/fixtures/csp-adapter/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/ssr.astro b/packages/astro/test/fixtures/csp-adapter/src/pages/ssr.astro similarity index 100% rename from packages/astro/test/fixtures/csp/src/pages/ssr.astro rename to packages/astro/test/fixtures/csp-adapter/src/pages/ssr.astro diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index 30cfe968e89a..c5f48d17646b 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -22,11 +22,13 @@ import { viteID } from '../dist/core/util.js'; */ export default function ({ provideAddress = true, + staticHeaders = false, extendAdapter, setEntryPoints, setMiddlewareEntryPoint, setRoutes, setManifest, + setRouteToHeaders, env, } = {}) { return { @@ -115,10 +117,10 @@ export default function ({ hybridOutput: 'stable', assets: 'stable', i18nDomains: 'stable', - cspHeader: 'stable', }, adapterFeatures: { buildOutput: 'server', + experimentalStaticHeaders: staticHeaders, }, ...extendAdapter, }); @@ -139,6 +141,11 @@ export default function ({ setRoutes(routes); } }, + 'astro:build:generated': ({ experimentalRouteToHeaders }) => { + if (setRouteToHeaders) { + setRouteToHeaders(experimentalRouteToHeaders); + } + }, }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 495e91ff7607..8ee7b0251fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2847,6 +2847,12 @@ importers: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + packages/astro/test/fixtures/csp-adapter: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/csrf-check-origin: dependencies: astro: