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',
);